// main.go // Einfaches Kücheninventar mit Weboberfläche und persistenter SQLite-Datenbank. // Features: // - Produkte: Name, Hersteller, Größe (Inhaltsmenge), Bild (Upload oder URL), bevorzugter Händler, Mindestbestand // - Bestände entstehen aus Einheiten (Chargen) mit je einem Ablaufdatum // - Automatischer Bestand je Produkt = Anzahl Einheiten mit Status "in" // - Vorschlag: Nächste ablaufende Einheit (global und je Produkt) mit Button "Ausbuchen" // - Warnungen bei Unterschreitung Mindestbestand // - Einkaufslisten pro Händler: Welche Artikel und Menge sind zu beschaffen (Min - Ist, falls > 0) // // Start: // go mod init kitcheninv // go get modernc.org/sqlite // go run . // // Danach im Browser: http://localhost:8080 // Uploads liegen im Ordner ./uploads package main import ( "database/sql" "embed" "fmt" "html/template" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" _ "modernc.org/sqlite" ) //go:embed templates/* assets/* var embeddedFS embed.FS type App struct { DB *sql.DB Templates *template.Template // (legacy – nicht genutzt für Rendering) UploadDir string } type Product struct { ID int Name string Manufacturer string Size string ImagePath string // lokal gespeicherter Pfad oder externe URL PreferredVendor string MinStock int CurrentStock int // berechnet } type Unit struct { ID int ProductID int ExpiryDate string // YYYY-MM-DD Status string // "in" | "out" AddedAt string Product *Product // optional befüllt bei JOINs } func main() { if err := os.MkdirAll("uploads", 0o755); err != nil { log.Fatalf("kann Upload-Verzeichnis nicht erstellen: %v", err) } dsn := "file:kueche.db?_pragma=busy_timeout(5000)&cache=shared" db, err := sql.Open("sqlite", dsn) if err != nil { log.Fatal(err) } if err := initDB(db); err != nil { log.Fatal(err) } // Templates werden pro View on-demand zusammengebaut, um Mehrfach-Definitionen zu vermeiden. tmpl := template.New("legacy") // Platzhalter – global ungenutzt app := &App{DB: db, Templates: tmpl /* legacy */, UploadDir: "uploads"} mux := http.NewServeMux() // Routen mux.HandleFunc("/", app.handleDashboard) mux.HandleFunc("/products", app.handleProducts) mux.HandleFunc("/products/create", app.handleCreateProduct) mux.HandleFunc("/products/", app.handleProductDetail) // /products/{id} mux.HandleFunc("/units/", app.handleUnitActions) // /units/{id}/checkout mux.HandleFunc("/alerts", app.handleAlerts) mux.HandleFunc("/shopping-list", app.handleShoppingList) // Statische Assets (CSS) mux.Handle("/assets/", http.FileServer(http.FS(embeddedFS))) // Uploads uploadFS := http.FileServer(http.Dir(app.UploadDir)) mux.Handle("/uploads/", http.StripPrefix("/uploads/", uploadFS)) addr := ":8080" log.Printf("Server läuft auf http://localhost%v", addr) if err := http.ListenAndServe(addr, securityHeaders(mux)); err != nil { log.Fatal(err) } } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer-when-downgrade") w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'") next.ServeHTTP(w, r) }) } // --- DB --- func initDB(db *sql.DB) error { if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { return err } // Schema schema := ` CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, manufacturer TEXT, size TEXT, image_path TEXT, preferred_vendor TEXT, min_stock INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS units ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, expiry_date TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'in', added_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_units_product_status ON units(product_id, status); CREATE INDEX IF NOT EXISTS idx_units_expiry_in ON units(expiry_date) WHERE status = 'in'; ` _, err := db.Exec(schema) return err } // Hilfsfunktionen func atoiDefault(s string, def int) int { i, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil { return def } return i } func (a *App) render(w http.ResponseWriter, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") files := []string{"templates/base.gohtml", "templates/partials.gohtml"} switch name { case "dashboard.gohtml": files = append(files, "templates/dashboard.gohtml") case "products.gohtml": files = append(files, "templates/products.gohtml") case "product_create.gohtml": files = append(files, "templates/product_create.gohtml") case "product_detail.gohtml": files = append(files, "templates/product_detail.gohtml") case "alerts.gohtml": files = append(files, "templates/alerts.gohtml") case "shopping_list.gohtml": files = append(files, "templates/shopping_list.gohtml") default: http.Error(w, "Unbekannte Ansicht", http.StatusInternalServerError) return } tmpl := template.Must(template.New("base").Funcs(template.FuncMap{ "dateHuman": func(iso string) string { if iso == "" { return "" } t, err := time.Parse("2006-01-02", iso) if err != nil { return iso } return t.Format("02.01.2006") }, "now": time.Now, }).ParseFS(embeddedFS, files...)) if err := tmpl.ExecuteTemplate(w, "base", data); err != nil { log.Println("template error:", err) http.Error(w, "Template-Fehler", http.StatusInternalServerError) } } func (a *App) loadProduct(id int) (*Product, error) { row := a.DB.QueryRow(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products WHERE id=?`, id) var p Product if err := row.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err != nil { return nil, err } stock, _ := a.productStock(p.ID) p.CurrentStock = stock return &p, nil } func (a *App) productStock(productID int) (int, error) { row := a.DB.QueryRow(`SELECT COUNT(1) FROM units WHERE product_id=? AND status='in'`, productID) var c int return c, row.Scan(&c) } func (a *App) nextExpiringGlobal() (*Unit, error) { row := a.DB.QueryRow(` SELECT u.id, u.product_id, u.expiry_date, u.status, u.added_at, p.id, p.name, p.manufacturer, p.size, p.image_path, p.preferred_vendor, p.min_stock FROM units u JOIN products p ON p.id = u.product_id WHERE u.status='in' ORDER BY u.expiry_date ASC LIMIT 1 `) var u Unit var p Product if err := row.Scan(&u.ID, &u.ProductID, &u.ExpiryDate, &u.Status, &u.AddedAt, &p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err != nil { return nil, err } stock, _ := a.productStock(p.ID) p.CurrentStock = stock u.Product = &p return &u, nil } // --- Handler --- func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } // alle Produkte laden rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`) if err != nil { http.Error(w, err.Error(), 500) return } defer rows.Close() var products []Product for rows.Next() { var p Product if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil { p.CurrentStock, _ = a.productStock(p.ID) products = append(products, p) } } var next *Unit if u, err := a.nextExpiringGlobal(); err == nil { next = u } // Warnungen zählen alerts := 0 for _, p := range products { if p.MinStock > 0 && p.CurrentStock < p.MinStock { alerts++ } } data := map[string]any{ "Products": products, "Next": next, "Alerts": alerts, } a.render(w, "dashboard.gohtml", data) } func (a *App) handleProducts(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`) if err != nil { http.Error(w, err.Error(), 500) return } defer rows.Close() var list []Product for rows.Next() { var p Product if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil { p.CurrentStock, _ = a.productStock(p.ID) list = append(list, p) } } data := map[string]any{ "Products": list, } a.render(w, "products.gohtml", data) return } if r.Method == http.MethodPost { http.Redirect(w, r, "/products/create", http.StatusSeeOther) return } w.WriteHeader(http.StatusMethodNotAllowed) } func (a *App) handleCreateProduct(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { a.render(w, "product_create.gohtml", nil) return } if r.Method == http.MethodPost { if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB http.Error(w, "Ungültiges Formular", 400) return } name := strings.TrimSpace(r.FormValue("name")) if name == "" { http.Error(w, "Name ist erforderlich", 400) return } manufacturer := strings.TrimSpace(r.FormValue("manufacturer")) size := strings.TrimSpace(r.FormValue("size")) preferredVendor := strings.TrimSpace(r.FormValue("preferred_vendor")) minStock := atoiDefault(r.FormValue("min_stock"), 0) imagePath := strings.TrimSpace(r.FormValue("image_url")) // Datei-Upload hat Priorität, falls vorhanden file, header, err := r.FormFile("image_file") if err == nil && header != nil { defer file.Close() ext := strings.ToLower(filepath.Ext(header.Filename)) if extOK(ext) { fname := fmt.Sprintf("%d_%s", time.Now().UnixNano(), sanitizeFilename(header.Filename)) outPath := filepath.Join(a.UploadDir, fname) out, err := os.Create(outPath) if err != nil { http.Error(w, "Upload fehlgeschlagen", 500) return } defer out.Close() if _, err := io.Copy(out, file); err != nil { http.Error(w, "Upload fehlgeschlagen", 500) return } imagePath = "/uploads/" + fname } } res, err := a.DB.Exec(`INSERT INTO products(name, manufacturer, size, image_path, preferred_vendor, min_stock) VALUES(?,?,?,?,?,?)`, name, manufacturer, size, imagePath, preferredVendor, minStock) if err != nil { http.Error(w, err.Error(), 500) return } id64, _ := res.LastInsertId() http.Redirect(w, r, fmt.Sprintf("/products/%d", id64), http.StatusSeeOther) return } w.WriteHeader(http.StatusMethodNotAllowed) } func sanitizeFilename(s string) string { s = strings.ReplaceAll(s, " ", "_") s = strings.ReplaceAll(s, ":", "-") return s } func extOK(ext string) bool { switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp": return true default: return false } } func (a *App) handleProductDetail(w http.ResponseWriter, r *http.Request) { // erwartetes Muster: /products/{id} oder Unterpfade /add-units parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/products/"), "/") if len(parts) == 0 || parts[0] == "" { http.NotFound(w, r) return } id := atoiDefault(parts[0], 0) if id == 0 { http.NotFound(w, r) return } if len(parts) == 2 && parts[1] == "add-units" { if r.Method == http.MethodPost { if err := r.ParseForm(); err != nil { http.Error(w, "Ungültiges Formular", 400) return } qty := atoiDefault(r.FormValue("quantity"), 0) exp := strings.TrimSpace(r.FormValue("expiry_date")) // YYYY-MM-DD if qty <= 0 || len(exp) != 10 { http.Error(w, "Menge und Datum erforderlich", 400) return } now := time.Now().Format(time.RFC3339) tx, err := a.DB.Begin() if err != nil { http.Error(w, err.Error(), 500) return } stmt, err := tx.Prepare(`INSERT INTO units(product_id, expiry_date, status, added_at) VALUES(?, ?, 'in', ?)`) if err != nil { tx.Rollback() http.Error(w, err.Error(), 500) return } for i := 0; i < qty; i++ { if _, err := stmt.Exec(id, exp, now); err != nil { tx.Rollback() http.Error(w, err.Error(), 500) return } } stmt.Close() if err := tx.Commit(); err != nil { http.Error(w, err.Error(), 500) return } http.Redirect(w, r, fmt.Sprintf("/products/%d", id), http.StatusSeeOther) return } w.WriteHeader(http.StatusMethodNotAllowed) return } // Details anzeigen, optional POST zur Produktbearbeitung (Min-Bestand etc.) if r.Method == http.MethodPost { if err := r.ParseForm(); err == nil { min := atoiDefault(r.FormValue("min_stock"), 0) pv := strings.TrimSpace(r.FormValue("preferred_vendor")) size := strings.TrimSpace(r.FormValue("size")) manufacturer := strings.TrimSpace(r.FormValue("manufacturer")) _, err := a.DB.Exec(`UPDATE products SET min_stock=?, preferred_vendor=?, size=?, manufacturer=? WHERE id=?`, min, pv, size, manufacturer, id) if err != nil { http.Error(w, err.Error(), 500) return } http.Redirect(w, r, fmt.Sprintf("/products/%d", id), http.StatusSeeOther) return } } p, err := a.loadProduct(id) if err != nil { http.Error(w, "Produkt nicht gefunden", 404) return } // Einheiten laden (nur "in") rows, err := a.DB.Query(`SELECT id, product_id, expiry_date, status, added_at FROM units WHERE product_id=? AND status='in' ORDER BY expiry_date ASC, id ASC`, id) if err != nil { http.Error(w, err.Error(), 500) return } defer rows.Close() var units []Unit for rows.Next() { var u Unit if err := rows.Scan(&u.ID, &u.ProductID, &u.ExpiryDate, &u.Status, &u.AddedAt); err == nil { units = append(units, u) } } var next *Unit if len(units) > 0 { n := units[0] n.Product = p next = &n } data := map[string]any{ "Product": p, "Units": units, "Next": next, } a.render(w, "product_detail.gohtml", data) } func (a *App) handleUnitActions(w http.ResponseWriter, r *http.Request) { // /units/{id}/checkout parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/units/"), "/") if len(parts) < 2 { http.NotFound(w, r) return } unitID := atoiDefault(parts[0], 0) action := parts[1] if unitID == 0 { http.NotFound(w, r) return } if action == "checkout" && r.Method == http.MethodPost { // hole Produkt-ID für Redirect var productID int row := a.DB.QueryRow(`SELECT product_id FROM units WHERE id=?`, unitID) if err := row.Scan(&productID); err != nil { http.Error(w, "Einheit nicht gefunden", 404) return } _, err := a.DB.Exec(`UPDATE units SET status='out' WHERE id=?`, unitID) if err != nil { http.Error(w, err.Error(), 500) return } // Entscheide Redirect: falls "from" übergeben, dorthin zurück from := r.URL.Query().Get("from") if from != "" { http.Redirect(w, r, from, http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/products/%d", productID), http.StatusSeeOther) return } w.WriteHeader(http.StatusMethodNotAllowed) } func (a *App) handleAlerts(w http.ResponseWriter, r *http.Request) { rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`) if err != nil { http.Error(w, err.Error(), 500) return } defer rows.Close() var below []Product for rows.Next() { var p Product if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil { p.CurrentStock, _ = a.productStock(p.ID) if p.MinStock > 0 && p.CurrentStock < p.MinStock { below = append(below, p) } } } data := map[string]any{ "Below": below, } a.render(w, "alerts.gohtml", data) } func (a *App) handleShoppingList(w http.ResponseWriter, r *http.Request) { // Lade alle Produkte mit aktuellem Bestand, gruppiere in Go nach Händler rows, err := a.DB.Query(` SELECT p.id, p.name, p.manufacturer, p.size, p.image_path, p.preferred_vendor, p.min_stock, COALESCE(SUM(CASE WHEN u.status='in' THEN 1 ELSE 0 END), 0) AS stock FROM products p LEFT JOIN units u ON u.product_id = p.id GROUP BY p.id ORDER BY p.preferred_vendor, p.name COLLATE NOCASE `) if err != nil { http.Error(w, err.Error(), 500) return } defer rows.Close() type Item struct { Product Needed int } group := map[string][]Item{} for rows.Next() { var p Product var stock int if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock, &stock); err == nil { p.CurrentStock = stock need := p.MinStock - stock if p.MinStock > 0 && need > 0 { key := p.PreferredVendor if strings.TrimSpace(key) == "" { key = "(Kein Händler)" } group[key] = append(group[key], Item{Product: p, Needed: need}) } } } // Für stabile Ausgabe auch Liste der Keys bauen var vendors []string for v := range group { vendors = append(vendors, v) } // einfache Sortierung der Vendor-Namen for i := 0; i < len(vendors); i++ { for j := i + 1; j < len(vendors); j++ { if strings.ToLower(vendors[j]) < strings.ToLower(vendors[i]) { vendors[i], vendors[j] = vendors[j], vendors[i] } } } data := map[string]any{ "Groups": group, "Vendors": vendors, } a.render(w, "shopping_list.gohtml", data) }