diff --git a/backend/go.mod b/backend/go.mod index 167d824..5f715bf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,13 +7,13 @@ require github.com/go-chi/chi/v5 v5.2.3 require ( github.com/go-chi/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/joho/godotenv v1.5.1 modernc.org/sqlite v1.39.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/backend/handlers/api.go b/backend/handlers/api.go new file mode 100644 index 0000000..81908cc --- /dev/null +++ b/backend/handlers/api.go @@ -0,0 +1,377 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/golang-jwt/jwt/v5" +) + +// GET current finalize time +func (app *App) HandleGetFinalizeTime(w http.ResponseWriter, r *http.Request) { + var t string + err := app.DB.QueryRow("SELECT value FROM settings WHERE key = 'finalize_time'").Scan(&t) + if err == sql.ErrNoRows { + t = "10:30" // default if not set + } + writeJSON(w, map[string]string{"time": t}) +} + +// Handle User Registration +func (app *App) HandleRegister(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if req.Username == "" || req.Password == "" { + http.Error(w, "username and password required", http.StatusBadRequest) + return + } + + res, err := app.DB.Exec( + "INSERT INTO users (username, password, role) VALUES (?, ?, ?)", + req.Username, req.Password, 100, + ) + if err != nil { + http.Error(w, "username already exists", http.StatusBadRequest) + return + } + id, _ := res.LastInsertId() + + token, err := generateToken(int(id), req.Username) + if err != nil { + http.Error(w, "could not generate token", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{ + "token": token, + "username": req.Username, + }) +} + +// Handle User Login +func (app *App) HandleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + var id int + var storedPassword string + err := app.DB.QueryRow("SELECT id, password FROM users WHERE username = ?", req.Username).Scan(&id, &storedPassword) + if err != nil { + http.Error(w, "invalid username or password", http.StatusUnauthorized) + return + } + + if storedPassword != req.Password { + http.Error(w, "invalid username or password", http.StatusUnauthorized) + return + } + + token, err := generateToken(id, req.Username) + if err != nil { + http.Error(w, "could not generate token", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{ + "token": token, + "username": req.Username, + }) +} + +// Get Menu Options from DB +func (app *App) HandleOptions(w http.ResponseWriter, r *http.Request) { + rows, err := app.DB.Query("SELECT category, name FROM menu_items ORDER BY id ASC") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + valasztek := Valasztek{ + Levesek: []Leves{}, + Foetelek: []Foetel{}, + Koretek: []Koret{}, + } + + for rows.Next() { + var category, name string + if err := rows.Scan(&category, &name); err == nil { + switch category { + case "soup": + valasztek.Levesek = append(valasztek.Levesek, name) + case "main": + valasztek.Foetelek = append(valasztek.Foetelek, name) + case "side": + valasztek.Koretek = append(valasztek.Koretek, name) + } + } + } + + writeJSON(w, valasztek) +} + +// Handle User Saved Selection +func (app *App) HandleSaveSelection(w http.ResponseWriter, r *http.Request) { + tokenStr := r.Header.Get("Authorization") + if tokenStr == "" { + http.Error(w, "missing token", http.StatusUnauthorized) + return + } + + // Strip "Bearer " prefix + if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { + tokenStr = tokenStr[7:] + } + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + if err != nil || !token.Valid { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + claims := token.Claims.(jwt.MapClaims) + userID := int(claims["user_id"].(float64)) + + var req selectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + now := time.Now().Format(time.RFC3339) + + // Insert or update selection (simple approach: delete old one, insert new) + _, _ = app.DB.Exec("DELETE FROM selections WHERE user_id = ?", userID) + _, err = app.DB.Exec( + "INSERT INTO selections (user_id, main, side, soup, created_at) VALUES (?, ?, ?, ?, ?)", + userID, req.Main, req.Side, req.Soup, now, + ) + if err != nil { + http.Error(w, "failed to save selection", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{ + "status": "ok", + "main": req.Main, + "side": req.Side, + "soup": req.Soup, + }) +} + +// Handle User's selected Menu +func (app *App) HandleGetSelection(w http.ResponseWriter, r *http.Request) { + tokenStr := r.Header.Get("Authorization") + if tokenStr == "" { + http.Error(w, "missing token", http.StatusUnauthorized) + return + } + if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { + tokenStr = tokenStr[7:] + } + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + if err != nil || !token.Valid { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + claims := token.Claims.(jwt.MapClaims) + userID := int(claims["user_id"].(float64)) + + row := app.DB.QueryRow("SELECT main, side, soup, created_at FROM selections WHERE user_id = ?", userID) + var main, side, soup, createdAt string + err = row.Scan(&main, &side, &soup, &createdAt) + if err == sql.ErrNoRows { + writeJSON(w, map[string]string{"status": "none"}) + return + } else if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{ + "main": main, + "side": side, + "soup": soup, + "created_at": createdAt, + }) +} + +// Get all active Orders +func (app *App) HandleGetOrders(w http.ResponseWriter, r *http.Request) { + rows, err := app.DB.Query("SELECT id, username, soup, main, side, created_at, status FROM orders WHERE status = 'active' ORDER BY id DESC") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var out []Order + for rows.Next() { + var o Order + if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + out = append(out, o) + } + writeJSON(w, out) +} + +// Handle new Order +func (app *App) HandleAddOrder(w http.ResponseWriter, r *http.Request) { + var in struct{ Soup, Main, Side string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Main == "" || in.Side == "" { + http.Error(w, "invalid order", http.StatusBadRequest) + return + } + username, err := usernameFromJWT(r) + if err != nil || username == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + now := time.Now().Format(time.RFC3339) + + // // Step 1: mark any previous active orders for this user as history + // _, _ = app.DB.Exec("UPDATE orders SET status = 'history' WHERE username = ? AND status = 'active'", username) + + // Step 2: insert new active order + res, err := app.DB.Exec( + "INSERT INTO orders (username, soup, main, side, created_at, status) VALUES (?, ?, ?, ?, ?, ?)", + username, in.Soup, in.Main, in.Side, now, "active", + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + id, _ := res.LastInsertId() + ord := Order{ + ID: int(id), + Username: username, + Soup: in.Soup, + Main: in.Main, + Side: in.Side, + CreatedAt: now, + Status: "active", + } + + // broadcast to SSE clients + if data, err := json.Marshal(ord); err == nil { + log.Printf("Broadcasting active order via SSE: %+v", ord) + app.Broker.broadcast <- data + } + + writeJSON(w, ord) +} + +// Handle Order deletion +func (app *App) HandleDeleteOrder(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + username, err := usernameFromJWT(r) + if err != nil || username == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + res, err := app.DB.Exec("UPDATE orders SET status = 'history' WHERE id = ? AND username = ?", id, username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + n, _ := res.RowsAffected() + if n == 0 { + http.Error(w, "order not found", http.StatusNotFound) + return + } + + // broadcast deletion (tell clients to refresh) + msg := map[string]any{"id": id, "status": "history", "event": "deleted"} + if data, err := json.Marshal(msg); err == nil { + app.Broker.broadcast <- data + } + + writeJSON(w, map[string]string{"status": "archived"}) +} + +// Send Orders over SSE to client +func (app *App) HandleOrdersStream(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Del("Content-Encoding") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unsupported", http.StatusInternalServerError) + return + } + + ch := make(chan []byte, 1) + app.Broker.add <- ch + defer func() { app.Broker.remove <- ch }() + + // open the stream + w.Write([]byte(":ok\n\n")) + flusher.Flush() + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + log.Println("SSE client disconnected") + return + case msg := <-ch: + log.Printf("SSE send: %s", string(msg)) + w.Write([]byte("data: ")) + w.Write(msg) + w.Write([]byte("\n\n")) + flusher.Flush() + case <-ticker.C: + // log.Println("SSE heartbeat -> :ping") + w.Write([]byte(":ping\n\n")) + flusher.Flush() + } + } +} + +// Get User's information and Role +func (app *App) HandleWhoAmI(w http.ResponseWriter, r *http.Request) { + username, err := usernameFromJWT(r) + if err != nil || username == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + role, err := app.getUserRole(username) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "username": username, + "role": role, + }) +} diff --git a/backend/handlers/api_admin.go b/backend/handlers/api_admin.go new file mode 100644 index 0000000..7b592f5 --- /dev/null +++ b/backend/handlers/api_admin.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (app *App) HandleAdminMenu(w http.ResponseWriter, r *http.Request) { + var in struct { + Action string `json:"action"` + ID int `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + switch in.Action { + case "add": + _, err := app.DB.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", in.Category, in.Name) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + case "update": + _, err := app.DB.Exec("UPDATE menu_items SET name = ? WHERE id = ?", in.Name, in.ID) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + case "delete": + _, err := app.DB.Exec("DELETE FROM menu_items WHERE id = ?", in.ID) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + } + + // return updated menu + rows, _ := app.DB.Query("SELECT id, category, name FROM menu_items ORDER BY id ASC") + defer rows.Close() + var items []map[string]any + for rows.Next() { + var id int + var category, name string + rows.Scan(&id, &category, &name) + items = append(items, map[string]any{"id": id, "category": category, "name": name}) + } + writeJSON(w, items) +} + +func (app *App) HandleAdminUpdateOrderStatus(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var in struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + _, err := app.DB.Exec("UPDATE orders SET status = ? WHERE id = ?", in.Status, id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + switch in.Status { + case "history": + // If status is history -> send deleted event + msg := map[string]any{"id": id, "status": "history", "event": "deleted"} + if data, err := json.Marshal(msg); err == nil { + app.Broker.broadcast <- data + } + case "active": + // If status is active -> send new order + var o Order + row := app.DB.QueryRow("SELECT id, username, soup, main, side, created_at, status FROM orders WHERE id = ?", id) + if err := row.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err == nil { + if data, err := json.Marshal(o); err == nil { + app.Broker.broadcast <- data + } + } + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (app *App) HandleAdminDeleteOrder(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // Step 1: set to history (so user views update immediately) + _, _ = app.DB.Exec("UPDATE orders SET status = 'history' WHERE id = ?", id) + + // Step 2: broadcast history update + msg := map[string]any{"id": id, "status": "history", "event": "deleted"} + if data, err := json.Marshal(msg); err == nil { + app.Broker.broadcast <- data + } + + // Step 3: hard delete from DB + _, err := app.DB.Exec("DELETE FROM orders WHERE id = ?", id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "deleted"}) +} + +func (app *App) HandleGetUsers(w http.ResponseWriter, r *http.Request) { + rows, err := app.DB.Query("SELECT id, username, role FROM users ORDER BY id") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var users []map[string]any + for rows.Next() { + var id, role int + var username string + rows.Scan(&id, &username, &role) + users = append(users, map[string]any{"id": id, "username": username, "role": role}) + } + + writeJSON(w, users) +} + +func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role *int `json:"role"` // pointer = optional + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if req.Username == "" && req.Password == "" && req.Role == nil { + http.Error(w, "nothing to update", http.StatusBadRequest) + return + } + + // build dynamic UPDATE + query := "UPDATE users SET " + args := []any{} + first := true + if req.Username != "" { + if !first { + query += ", " + } + first = false + query += "username = ?" + args = append(args, req.Username) + } + if req.Password != "" { + if !first { + query += ", " + } + first = false + query += "password = ?" + args = append(args, req.Password) + } + if req.Role != nil { + if !first { + query += ", " + } + first = false + query += "role = ?" + args = append(args, *req.Role) + } + query += " WHERE id = ?" + args = append(args, id) + + res, err := app.DB.Exec(query, args...) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if n, _ := res.RowsAffected(); n == 0 { + http.Error(w, "user not found", http.StatusNotFound) + return + } + writeJSON(w, map[string]string{"status": "updated"}) +} + +func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + res, err := app.DB.Exec("DELETE FROM users WHERE id = ?", id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + n, _ := res.RowsAffected() + if n == 0 { + http.Error(w, "user not found", http.StatusNotFound) + return + } + + writeJSON(w, map[string]string{"status": "deleted"}) +} + +func (app *App) HandleGetAllOrders(w http.ResponseWriter, r *http.Request) { + rows, err := app.DB.Query("SELECT id, username, soup, main, side, created_at, status FROM orders ORDER BY id DESC") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var out []Order + for rows.Next() { + var o Order + if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + out = append(out, o) + } + writeJSON(w, out) +} diff --git a/backend/handlers/api_moderator.go b/backend/handlers/api_moderator.go new file mode 100644 index 0000000..cada28f --- /dev/null +++ b/backend/handlers/api_moderator.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +func (app *App) HandleGetMenuRaw(w http.ResponseWriter, r *http.Request) { + rows, err := app.DB.Query("SELECT id, category, name FROM menu_items ORDER BY id ASC") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var items []map[string]any + for rows.Next() { + var id int + var category, name string + if err := rows.Scan(&id, &category, &name); err == nil { + items = append(items, map[string]any{ + "id": id, + "category": category, + "name": name, + }) + } + } + + writeJSON(w, items) +} + +// POST new finalize time +func (app *App) HandleSetFinalizeTime(w http.ResponseWriter, r *http.Request) { + var in struct { + Time string `json:"time"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "bad request", 400) + return + } + + _, _ = app.DB.Exec(`INSERT OR REPLACE INTO settings (key,value) VALUES ('finalize_time', ?)`, in.Time) + + // notify scheduler + select { + case app.FinalizeUpdate <- struct{}{}: + default: // don’t block if already queued + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// POST trigger finalize now +func (app *App) HandleFinalizeNow(w http.ResponseWriter, r *http.Request) { + if err := finalizeOrders(app); err != nil { + http.Error(w, "failed", 500) + return + } + writeJSON(w, map[string]string{"status": "done"}) +} + +func (app *App) HandleGetLastSummary(w http.ResponseWriter, r *http.Request) { + if app.LastSummary == nil { + writeJSON(w, map[string]string{"status": "none"}) + return + } + writeJSON(w, app.LastSummary) +} diff --git a/backend/handlers/app.go b/backend/handlers/app.go new file mode 100644 index 0000000..a7708ad --- /dev/null +++ b/backend/handlers/app.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +type FinalizedSummary struct { + Pickup []string `json:"pickup"` + Kitchen []string `json:"kitchen"` +} + +type App struct { + DB *sql.DB + Broker *Broker + FinalizeUpdate chan struct{} // notify scheduler to reload time + LastSummary *FinalizedSummary +} + +func NewApp(db *sql.DB, broker *Broker, finalizeUpdate chan struct{}) *App { + return &App{ + DB: db, + Broker: broker, + FinalizeUpdate: finalizeUpdate, + } +} + +func (app *App) RequireLevel(maxAllowed int) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, err := usernameFromJWT(r) + if err != nil || username == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + role, err := app.getUserRole(username) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + + if role > maxAllowed { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (app *App) getUserRole(username string) (int, error) { + var role int + err := app.DB.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role) + return role, err +} + +func finalizeOrders(app *App) error { + rows, err := app.DB.Query("SELECT id, username, soup, main, side FROM orders WHERE status = 'active'") + if err != nil { + return err + } + defer rows.Close() + + var summary []Order + for rows.Next() { + var o Order + if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side); err == nil { + summary = append(summary, o) + } + } + + // Step 2: send summary (log, email, or message) + sendSummary(app, summary) + + // Step 3: archive + _, err = app.DB.Exec("UPDATE orders SET status = 'history' WHERE status = 'active'") + + // Step 4: broadcast deletions to SSE clients + for _, o := range summary { + msg := map[string]any{"id": o.ID, "status": "history", "event": "deleted"} + if data, err := json.Marshal(msg); err == nil { + app.Broker.broadcast <- data + } + time.Sleep(20 * time.Millisecond) + } + + return err +} + +func sendSummary(app *App, orders []Order) *FinalizedSummary { + // Pickup view + userMap := make(map[string][]string) + for _, o := range orders { + items := []string{} + if o.Soup != "" { + items = append(items, o.Soup) + } + items = append(items, o.Main, o.Side) + userMap[o.Username] = append(userMap[o.Username], items...) + } + pickup := []string{} + for user, items := range userMap { + pickup = append(pickup, fmt.Sprintf("%s: [%s]", user, strings.Join(items, ", "))) + } + + // Kitchen view + kitchenMap := make(map[string]int) + for _, o := range orders { + if o.Soup != "" { + kitchenMap[o.Soup]++ + } + kitchenMap[o.Main]++ + kitchenMap[o.Side]++ + } + kitchen := []string{} + for item, count := range kitchenMap { + kitchen = append(kitchen, fmt.Sprintf("%s: %d", item, count)) + } + + summary := &FinalizedSummary{Pickup: pickup, Kitchen: kitchen} + app.LastSummary = summary + + // Log + log.Println("=== Pickup view ===") + for _, line := range pickup { + log.Println(line) + } + log.Println("=== Kitchen view ===") + for _, line := range kitchen { + log.Println(line) + } + + return summary +} + +func (app *App) getSetting(key, def string) string { + var v string + err := app.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&v) + if err != nil || v == "" { + return def + } + return v +} diff --git a/backend/handlers/broker.go b/backend/handlers/broker.go new file mode 100644 index 0000000..087063b --- /dev/null +++ b/backend/handlers/broker.go @@ -0,0 +1,45 @@ +package handlers + +import "log" + +type Broker struct { + clients map[chan []byte]bool + add chan chan []byte + remove chan chan []byte + broadcast chan []byte +} + +func NewBroker() *Broker { + b := &Broker{ + clients: make(map[chan []byte]bool), + add: make(chan chan []byte), + remove: make(chan chan []byte), + broadcast: make(chan []byte), + } + go func() { + for { + select { + case c := <-b.add: + b.clients[c] = true + log.Printf("SSE client added. Total: %d", len(b.clients)) + case c := <-b.remove: + if _, ok := b.clients[c]; ok { + delete(b.clients, c) + close(c) + log.Printf("SSE client removed. Total: %d", len(b.clients)) + } + case msg := <-b.broadcast: + log.Printf("Broker broadcasting to %d clients", len(b.clients)) + for c := range b.clients { + select { + case c <- msg: + log.Println("Message queued for client") + default: + log.Println("Client channel full, skipping") + } + } + } + } + }() + return b +} diff --git a/backend/handlers/daily_cleanup.go b/backend/handlers/daily_cleanup.go new file mode 100644 index 0000000..edd05ac --- /dev/null +++ b/backend/handlers/daily_cleanup.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "log/slog" + "strconv" + "strings" + "time" +) + +func StartDailyCleanup(app *App) { + loc, _ := time.LoadLocation("Europe/Budapest") + + go func() { + for { + // read finalize_time from DB (default "10:30") + t := app.getSetting("finalize_time", "10:30") + + // parse "HH:MM" + parts := strings.Split(t, ":") + hour, _ := strconv.Atoi(parts[0]) + minute, _ := strconv.Atoi(parts[1]) + + now := time.Now().In(loc) + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + + slog.Info("Next finalize scheduled", "time", next) + + timer := time.NewTimer(time.Until(next)) + + select { + case <-timer.C: + if err := finalizeOrders(app); err != nil { + slog.Error("Daily finalize failed", "error", err) + } else { + slog.Info("Orders finalized", "time", time.Now().In(loc)) + } + case <-app.FinalizeUpdate: + slog.Info("Finalize time updated, recalculating...") + timer.Stop() + } + } + }() +} diff --git a/backend/handlers/json_helpers.go b/backend/handlers/json_helpers.go new file mode 100644 index 0000000..ae95f7c --- /dev/null +++ b/backend/handlers/json_helpers.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/backend/handlers/jwt.go b/backend/handlers/jwt.go new file mode 100644 index 0000000..67eeb1c --- /dev/null +++ b/backend/handlers/jwt.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret = []byte("supersecretkey") // TODO: move to env variable + +func generateToken(userID int, username string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "username": username, + "exp": time.Now().Add(2 * time.Hour).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +func usernameFromJWT(r *http.Request) (string, error) { + auth := r.Header.Get("Authorization") + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return "", nil + } + tokenStr := parts[1] + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return jwtSecret, nil }) + if err != nil || !token.Valid { + return "", err + } + if claims, ok := token.Claims.(jwt.MapClaims); ok { + if u, ok := claims["username"].(string); ok { + return u, nil + } + } + return "", nil +} diff --git a/backend/handlers/types.go b/backend/handlers/types.go new file mode 100644 index 0000000..20d8165 --- /dev/null +++ b/backend/handlers/types.go @@ -0,0 +1,45 @@ +package handlers + +type ( + Foetel = string + Leves = string + Koret = string +) + +type Valasztek struct { + Foetelek []Foetel `json:"foetelek"` + Levesek []Leves `json:"levesek"` + Koretek []Koret `json:"koretek"` +} + +type Menu struct { + Foetelek []Foetel + Levesek []Leves + Koretek []Koret +} + +type Order struct { + ID int `json:"id"` + Username string `json:"username"` + Soup string `json:"soup"` + Main string `json:"main"` + Side string `json:"side"` + CreatedAt string `json:"created_at"` + Status string `json:"status"` +} + +type registerRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type selectionRequest struct { + Main string `json:"main"` + Side string `json:"side"` + Soup string `json:"soup"` +} diff --git a/backend/main.go b/backend/main.go index 4cbb247..91fa9a4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,1052 +1,56 @@ package main import ( - "database/sql" - "encoding/json" - "fmt" - "log" + "log/slog" "net/http" "os" - "strconv" - "strings" - "time" + + "menu/handlers" "github.com/joho/godotenv" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" - "github.com/golang-jwt/jwt/v5" ) -type ( - Foetel = string - Leves = string - Koret = string +var ( + Version = "0.8.0" + BuildTime = "-" ) -type Valasztek struct { - Foetelek []Foetel `json:"foetelek"` - Levesek []Leves `json:"levesek"` - Koretek []Koret `json:"koretek"` -} - -type Menu struct { - Foetelek []Foetel - Levesek []Leves - Koretek []Koret -} - -type Order struct { - ID int `json:"id"` - Username string `json:"username"` - Soup string `json:"soup"` - Main string `json:"main"` - Side string `json:"side"` - CreatedAt string `json:"created_at"` - Status string `json:"status"` -} - -type FinalizedSummary struct { - Pickup []string `json:"pickup"` - Kitchen []string `json:"kitchen"` -} - -type App struct { - DB *sql.DB - Broker *Broker - FinalizeUpdate chan struct{} // notify scheduler to reload time - LastSummary *FinalizedSummary -} - -type Broker struct { - clients map[chan []byte]bool - add chan chan []byte - remove chan chan []byte - broadcast chan []byte -} - -type registerRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type loginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type selectionRequest struct { - Main string `json:"main"` - Side string `json:"side"` - Soup string `json:"soup"` -} - -var jwtSecret = []byte("supersecretkey") // TODO: move to env variable - -func generateToken(userID int, username string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "username": username, - "exp": time.Now().Add(2 * time.Hour).Unix(), - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) -} - -func writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func (app *App) handleRegister(w http.ResponseWriter, r *http.Request) { - var req registerRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - if req.Username == "" || req.Password == "" { - http.Error(w, "username and password required", http.StatusBadRequest) - return - } - - res, err := app.DB.Exec( - "INSERT INTO users (username, password, role) VALUES (?, ?, ?)", - req.Username, req.Password, 100, - ) - if err != nil { - http.Error(w, "username already exists", http.StatusBadRequest) - return - } - id, _ := res.LastInsertId() - - token, err := generateToken(int(id), req.Username) - if err != nil { - http.Error(w, "could not generate token", http.StatusInternalServerError) - return - } - - writeJSON(w, map[string]string{ - "token": token, - "username": req.Username, - }) -} - -func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { - var req loginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - var id int - var storedPassword string - err := app.DB.QueryRow("SELECT id, password FROM users WHERE username = ?", req.Username).Scan(&id, &storedPassword) - if err != nil { - http.Error(w, "invalid username or password", http.StatusUnauthorized) - return - } - - if storedPassword != req.Password { - http.Error(w, "invalid username or password", http.StatusUnauthorized) - return - } - - token, err := generateToken(id, req.Username) - if err != nil { - http.Error(w, "could not generate token", http.StatusInternalServerError) - return - } - - writeJSON(w, map[string]string{ - "token": token, - "username": req.Username, - }) -} - -func usernameFromJWT(r *http.Request) (string, error) { - auth := r.Header.Get("Authorization") - parts := strings.SplitN(auth, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - return "", nil - } - tokenStr := parts[1] - - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return jwtSecret, nil }) - if err != nil || !token.Valid { - return "", err - } - if claims, ok := token.Claims.(jwt.MapClaims); ok { - if u, ok := claims["username"].(string); ok { - return u, nil - } - } - return "", nil -} - -func newBroker() *Broker { - b := &Broker{ - clients: make(map[chan []byte]bool), - add: make(chan chan []byte), - remove: make(chan chan []byte), - broadcast: make(chan []byte), - } - go func() { - for { - select { - case c := <-b.add: - b.clients[c] = true - log.Printf("SSE client added. Total: %d", len(b.clients)) - case c := <-b.remove: - if _, ok := b.clients[c]; ok { - delete(b.clients, c) - close(c) - log.Printf("SSE client removed. Total: %d", len(b.clients)) - } - case msg := <-b.broadcast: - log.Printf("Broker broadcasting to %d clients", len(b.clients)) - for c := range b.clients { - select { - case c <- msg: - log.Println("Message queued for client") - default: - log.Println("Client channel full, skipping") - } - } - } - } - }() - return b -} - -func (app *App) handleGetOrders(w http.ResponseWriter, r *http.Request) { - rows, err := app.DB.Query("SELECT id, username, soup, main, side, created_at, status FROM orders WHERE status = 'active' ORDER BY id DESC") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - var out []Order - for rows.Next() { - var o Order - if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - out = append(out, o) - } - writeJSON(w, out) -} - -func (app *App) handleAddOrder(w http.ResponseWriter, r *http.Request) { - var in struct{ Soup, Main, Side string } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Main == "" || in.Side == "" { - http.Error(w, "invalid order", http.StatusBadRequest) - return - } - username, err := usernameFromJWT(r) - if err != nil || username == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - now := time.Now().Format(time.RFC3339) - - // // Step 1: mark any previous active orders for this user as history - // _, _ = app.DB.Exec("UPDATE orders SET status = 'history' WHERE username = ? AND status = 'active'", username) - - // Step 2: insert new active order - res, err := app.DB.Exec( - "INSERT INTO orders (username, soup, main, side, created_at, status) VALUES (?, ?, ?, ?, ?, ?)", - username, in.Soup, in.Main, in.Side, now, "active", - ) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - id, _ := res.LastInsertId() - ord := Order{ - ID: int(id), - Username: username, - Soup: in.Soup, - Main: in.Main, - Side: in.Side, - CreatedAt: now, - Status: "active", - } - - // broadcast to SSE clients - if data, err := json.Marshal(ord); err == nil { - log.Printf("Broadcasting active order via SSE: %+v", ord) - app.Broker.broadcast <- data - } - - writeJSON(w, ord) -} - -func (app *App) handleDeleteOrder(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - username, err := usernameFromJWT(r) - if err != nil || username == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - res, err := app.DB.Exec("UPDATE orders SET status = 'history' WHERE id = ? AND username = ?", id, username) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - n, _ := res.RowsAffected() - if n == 0 { - http.Error(w, "order not found", http.StatusNotFound) - return - } - - // broadcast deletion (tell clients to refresh) - msg := map[string]any{"id": id, "status": "history", "event": "deleted"} - if data, err := json.Marshal(msg); err == nil { - app.Broker.broadcast <- data - } - - writeJSON(w, map[string]string{"status": "archived"}) -} - -func (app *App) handleOrdersStream(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Del("Content-Encoding") - w.Header().Set("X-Accel-Buffering", "no") - - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "stream unsupported", http.StatusInternalServerError) - return - } - - ch := make(chan []byte, 1) - app.Broker.add <- ch - defer func() { app.Broker.remove <- ch }() - - // open the stream - w.Write([]byte(":ok\n\n")) - flusher.Flush() - - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - - for { - select { - case <-r.Context().Done(): - log.Println("SSE client disconnected") - return - case msg := <-ch: - log.Printf("SSE send: %s", string(msg)) - w.Write([]byte("data: ")) - w.Write(msg) - w.Write([]byte("\n\n")) - flusher.Flush() - case <-ticker.C: - // log.Println("SSE heartbeat -> :ping") - w.Write([]byte(":ping\n\n")) - flusher.Flush() - } - } -} - -func (app *App) handleSaveSelection(w http.ResponseWriter, r *http.Request) { - tokenStr := r.Header.Get("Authorization") - if tokenStr == "" { - http.Error(w, "missing token", http.StatusUnauthorized) - return - } - - // Strip "Bearer " prefix - if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { - tokenStr = tokenStr[7:] - } - - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { - return jwtSecret, nil - }) - if err != nil || !token.Valid { - http.Error(w, "invalid token", http.StatusUnauthorized) - return - } - - claims := token.Claims.(jwt.MapClaims) - userID := int(claims["user_id"].(float64)) - - var req selectionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - now := time.Now().Format(time.RFC3339) - - // Insert or update selection (simple approach: delete old one, insert new) - _, _ = app.DB.Exec("DELETE FROM selections WHERE user_id = ?", userID) - _, err = app.DB.Exec( - "INSERT INTO selections (user_id, main, side, soup, created_at) VALUES (?, ?, ?, ?, ?)", - userID, req.Main, req.Side, req.Soup, now, - ) - if err != nil { - http.Error(w, "failed to save selection", http.StatusInternalServerError) - return - } - - writeJSON(w, map[string]string{ - "status": "ok", - "main": req.Main, - "side": req.Side, - "soup": req.Soup, - }) -} - -func (app *App) handleGetSelection(w http.ResponseWriter, r *http.Request) { - tokenStr := r.Header.Get("Authorization") - if tokenStr == "" { - http.Error(w, "missing token", http.StatusUnauthorized) - return - } - if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { - tokenStr = tokenStr[7:] - } - - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { - return jwtSecret, nil - }) - if err != nil || !token.Valid { - http.Error(w, "invalid token", http.StatusUnauthorized) - return - } - - claims := token.Claims.(jwt.MapClaims) - userID := int(claims["user_id"].(float64)) - - row := app.DB.QueryRow("SELECT main, side, soup, created_at FROM selections WHERE user_id = ?", userID) - var main, side, soup, createdAt string - err = row.Scan(&main, &side, &soup, &createdAt) - if err == sql.ErrNoRows { - writeJSON(w, map[string]string{"status": "none"}) - return - } else if err != nil { - http.Error(w, "db error", http.StatusInternalServerError) - return - } - - writeJSON(w, map[string]string{ - "main": main, - "side": side, - "soup": soup, - "created_at": createdAt, - }) -} - -func (app *App) handleOptions(w http.ResponseWriter, r *http.Request) { - rows, err := app.DB.Query("SELECT category, name FROM menu_items ORDER BY id ASC") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - valasztek := Valasztek{ - Levesek: []Leves{}, - Foetelek: []Foetel{}, - Koretek: []Koret{}, - } - - for rows.Next() { - var category, name string - if err := rows.Scan(&category, &name); err == nil { - switch category { - case "soup": - valasztek.Levesek = append(valasztek.Levesek, name) - case "main": - valasztek.Foetelek = append(valasztek.Foetelek, name) - case "side": - valasztek.Koretek = append(valasztek.Koretek, name) - } - } - } - - writeJSON(w, valasztek) -} - -func (app *App) handleGetMenuRaw(w http.ResponseWriter, r *http.Request) { - rows, err := app.DB.Query("SELECT id, category, name FROM menu_items ORDER BY id ASC") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - var items []map[string]any - for rows.Next() { - var id int - var category, name string - if err := rows.Scan(&id, &category, &name); err == nil { - items = append(items, map[string]any{ - "id": id, - "category": category, - "name": name, - }) - } - } - - writeJSON(w, items) -} - -func (app *App) getUserRole(username string) (int, error) { - var role int - err := app.DB.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role) - return role, err -} - -func (app *App) requireLevel(maxAllowed int) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - username, err := usernameFromJWT(r) - if err != nil || username == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - role, err := app.getUserRole(username) - if err != nil { - http.Error(w, "db error", http.StatusInternalServerError) - return - } - - if role > maxAllowed { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }) - } -} - -func (app *App) handleWhoAmI(w http.ResponseWriter, r *http.Request) { - username, err := usernameFromJWT(r) - if err != nil || username == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - role, err := app.getUserRole(username) - if err != nil { - http.Error(w, "db error", http.StatusInternalServerError) - return - } - - writeJSON(w, map[string]any{ - "username": username, - "role": role, - }) -} - -func (app *App) handleGetUsers(w http.ResponseWriter, r *http.Request) { - rows, err := app.DB.Query("SELECT id, username, role FROM users ORDER BY id") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - var users []map[string]any - for rows.Next() { - var id, role int - var username string - rows.Scan(&id, &username, &role) - users = append(users, map[string]any{"id": id, "username": username, "role": role}) - } - - writeJSON(w, users) -} - -func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - res, err := app.DB.Exec("DELETE FROM users WHERE id = ?", id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - n, _ := res.RowsAffected() - if n == 0 { - http.Error(w, "user not found", http.StatusNotFound) - return - } - - writeJSON(w, map[string]string{"status": "deleted"}) -} - -// main.go -func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - var req struct { - Username string `json:"username"` - Password string `json:"password"` - Role *int `json:"role"` // pointer = optional - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - if req.Username == "" && req.Password == "" && req.Role == nil { - http.Error(w, "nothing to update", http.StatusBadRequest) - return - } - - // build dynamic UPDATE - query := "UPDATE users SET " - args := []any{} - first := true - if req.Username != "" { - if !first { - query += ", " - } - first = false - query += "username = ?" - args = append(args, req.Username) - } - if req.Password != "" { - if !first { - query += ", " - } - first = false - query += "password = ?" - args = append(args, req.Password) - } - if req.Role != nil { - if !first { - query += ", " - } - first = false - query += "role = ?" - args = append(args, *req.Role) - } - query += " WHERE id = ?" - args = append(args, id) - - res, err := app.DB.Exec(query, args...) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if n, _ := res.RowsAffected(); n == 0 { - http.Error(w, "user not found", http.StatusNotFound) - return - } - writeJSON(w, map[string]string{"status": "updated"}) -} - -func (app *App) handleAdminMenu(w http.ResponseWriter, r *http.Request) { - var in struct { - Action string `json:"action"` - ID int `json:"id"` - Name string `json:"name"` - Category string `json:"category"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - switch in.Action { - case "add": - _, err := app.DB.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", in.Category, in.Name) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - case "update": - _, err := app.DB.Exec("UPDATE menu_items SET name = ? WHERE id = ?", in.Name, in.ID) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - case "delete": - _, err := app.DB.Exec("DELETE FROM menu_items WHERE id = ?", in.ID) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - } - - // return updated menu - rows, _ := app.DB.Query("SELECT id, category, name FROM menu_items ORDER BY id ASC") - defer rows.Close() - var items []map[string]any - for rows.Next() { - var id int - var category, name string - rows.Scan(&id, &category, &name) - items = append(items, map[string]any{"id": id, "category": category, "name": name}) - } - writeJSON(w, items) -} - -func (app *App) handleGetAllOrders(w http.ResponseWriter, r *http.Request) { - rows, err := app.DB.Query("SELECT id, username, soup, main, side, created_at, status FROM orders ORDER BY id DESC") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - var out []Order - for rows.Next() { - var o Order - if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - out = append(out, o) - } - writeJSON(w, out) -} - -func (app *App) handleAdminUpdateOrderStatus(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - var in struct { - Status string `json:"status"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - _, err := app.DB.Exec("UPDATE orders SET status = ? WHERE id = ?", in.Status, id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - switch in.Status { - case "history": - // If status is history -> send deleted event - msg := map[string]any{"id": id, "status": "history", "event": "deleted"} - if data, err := json.Marshal(msg); err == nil { - app.Broker.broadcast <- data - } - case "active": - // If status is active -> send new order - var o Order - row := app.DB.QueryRow("SELECT id, username, soup, main, side, created_at, status FROM orders WHERE id = ?", id) - if err := row.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err == nil { - if data, err := json.Marshal(o); err == nil { - app.Broker.broadcast <- data - } - } - } - - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (app *App) handleAdminDeleteOrder(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - // Step 1: set to history (so user views update immediately) - _, _ = app.DB.Exec("UPDATE orders SET status = 'history' WHERE id = ?", id) - - // Step 2: broadcast history update - msg := map[string]any{"id": id, "status": "history", "event": "deleted"} - if data, err := json.Marshal(msg); err == nil { - app.Broker.broadcast <- data - } - - // Step 3: hard delete from DB - _, err := app.DB.Exec("DELETE FROM orders WHERE id = ?", id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, map[string]string{"status": "deleted"}) -} - -// GET current finalize time -func (app *App) handleGetFinalizeTime(w http.ResponseWriter, r *http.Request) { - var t string - err := app.DB.QueryRow("SELECT value FROM settings WHERE key = 'finalize_time'").Scan(&t) - if err == sql.ErrNoRows { - t = "10:30" // default if not set - } - writeJSON(w, map[string]string{"time": t}) -} - -// POST new finalize time -func (app *App) handleSetFinalizeTime(w http.ResponseWriter, r *http.Request) { - var in struct { - Time string `json:"time"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - http.Error(w, "bad request", 400) - return - } - - _, _ = app.DB.Exec(`INSERT OR REPLACE INTO settings (key,value) VALUES ('finalize_time', ?)`, in.Time) - - // notify scheduler - select { - case app.FinalizeUpdate <- struct{}{}: - default: // don’t block if already queued - } - - writeJSON(w, map[string]string{"status": "ok"}) -} - -// POST trigger finalize now -func (app *App) handleFinalizeNow(w http.ResponseWriter, r *http.Request) { - if err := finalizeOrders(app); err != nil { - http.Error(w, "failed", 500) - return - } - writeJSON(w, map[string]string{"status": "done"}) -} - -func (app *App) getSetting(key, def string) string { - var v string - err := app.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&v) - if err != nil || v == "" { - return def - } - return v -} - -func startDailyCleanup(app *App) { - loc, _ := time.LoadLocation("Europe/Budapest") - - go func() { - for { - // read finalize_time from DB (default "10:30") - t := app.getSetting("finalize_time", "10:30") - - // parse "HH:MM" - parts := strings.Split(t, ":") - hour, _ := strconv.Atoi(parts[0]) - minute, _ := strconv.Atoi(parts[1]) - - now := time.Now().In(loc) - next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) - if !next.After(now) { - next = next.Add(24 * time.Hour) - } - - log.Println("Next finalize scheduled at", next) - - timer := time.NewTimer(time.Until(next)) - - select { - case <-timer.C: - if err := finalizeOrders(app); err != nil { - log.Println("daily finalize failed:", err) - } else { - log.Println("orders finalized at", time.Now().In(loc)) - } - case <-app.FinalizeUpdate: - log.Println("Finalize time updated, recalculating…") - timer.Stop() - } - } - }() -} - -func finalizeOrders(app *App) error { - rows, err := app.DB.Query("SELECT id, username, soup, main, side FROM orders WHERE status = 'active'") - if err != nil { - return err - } - defer rows.Close() - - var summary []Order - for rows.Next() { - var o Order - if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side); err == nil { - summary = append(summary, o) - } - } - - // Step 2: send summary (log, email, or message) - sendSummary(app, summary) - - // Step 3: archive - _, err = app.DB.Exec("UPDATE orders SET status = 'history' WHERE status = 'active'") - - // Step 4: broadcast deletions to SSE clients - for _, o := range summary { - msg := map[string]any{"id": o.ID, "status": "history", "event": "deleted"} - if data, err := json.Marshal(msg); err == nil { - app.Broker.broadcast <- data - } - time.Sleep(20 * time.Millisecond) - } - - return err -} - -func sendSummary(app *App, orders []Order) *FinalizedSummary { - // Pickup view - userMap := make(map[string][]string) - for _, o := range orders { - items := []string{} - if o.Soup != "" { - items = append(items, o.Soup) - } - items = append(items, o.Main, o.Side) - userMap[o.Username] = append(userMap[o.Username], items...) - } - pickup := []string{} - for user, items := range userMap { - pickup = append(pickup, fmt.Sprintf("%s: [%s]", user, strings.Join(items, ", "))) - } - - // Kitchen view - kitchenMap := make(map[string]int) - for _, o := range orders { - if o.Soup != "" { - kitchenMap[o.Soup]++ - } - kitchenMap[o.Main]++ - kitchenMap[o.Side]++ - } - kitchen := []string{} - for item, count := range kitchenMap { - kitchen = append(kitchen, fmt.Sprintf("%s: %d", item, count)) - } - - summary := &FinalizedSummary{Pickup: pickup, Kitchen: kitchen} - app.LastSummary = summary - - // Log - log.Println("=== Pickup view ===") - for _, line := range pickup { - log.Println(line) - } - log.Println("=== Kitchen view ===") - for _, line := range kitchen { - log.Println(line) - } - - return summary -} - -func (app *App) handleGetLastSummary(w http.ResponseWriter, r *http.Request) { - if app.LastSummary == nil { - writeJSON(w, map[string]string{"status": "none"}) - return - } - writeJSON(w, app.LastSummary) -} - -func server(app *App) *http.Server { - r := chi.NewRouter() - - // Middleware - r.Use(middleware.RequestID) - r.Use(middleware.Recoverer) - r.Use(middleware.Logger) - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, - AllowCredentials: true, - MaxAge: 300, - })) - - // Routes (bind methods to app) - r.Post("/api/register", app.handleRegister) - r.Post("/api/login", app.handleLogin) - r.Get("/api/options", app.handleOptions) - r.Post("/api/selection", app.handleSaveSelection) - r.Get("/api/selection", app.handleGetSelection) - r.Get("/api/orders", app.handleGetOrders) - r.Post("/api/orders", app.handleAddOrder) - r.Get("/api/orders/stream", app.handleOrdersStream) - r.Delete("/api/orders/{id}", app.handleDeleteOrder) - r.Get("/api/me", app.handleWhoAmI) - r.Get("/api/finalize/time", app.handleGetFinalizeTime) - - // Only role 0 and 1 allowed - r.Route("/api/admin", func(r chi.Router) { - r.Use(app.requireLevel(1)) - r.Get("/users", app.handleGetUsers) - r.Post("/menu", app.handleAdminMenu) - r.Get("/menu", app.handleGetMenuRaw) - r.Get("/users", app.handleGetUsers) - r.Delete("/users/{id}", app.handleDeleteUser) - r.Put("/users/{id}", app.handleUpdateUser) - r.Get("/orders", app.handleGetAllOrders) - r.Put("/orders/{id}/status", app.handleAdminUpdateOrderStatus) - r.Delete("/orders/{id}", app.handleAdminDeleteOrder) - r.Get("/finalize/time", app.handleGetFinalizeTime) - r.Post("/finalize/time", app.handleSetFinalizeTime) - r.Post("/finalize/now", app.handleFinalizeNow) - r.Get("/finalize/last", app.handleGetLastSummary) - }) - - // Moderators (role <= 50) - r.Route("/api/mod", func(r chi.Router) { - r.Use(app.requireLevel(50)) - r.Post("/menu", app.handleAdminMenu) // menu editor - r.Get("/menu", app.handleGetMenuRaw) - r.Get("/finalize/time", app.handleGetFinalizeTime) - r.Post("/finalize/time", app.handleSetFinalizeTime) - r.Post("/finalize/now", app.handleFinalizeNow) - r.Get("/finalize/last", app.handleGetLastSummary) - }) - - // Return configured server - return &http.Server{ - Addr: "0.0.0.0:7153", - Handler: r, - } -} - func main() { + setupDefaultLogger() + err := godotenv.Load() if err != nil { - log.Println("Cannot find .env file, using system env variables") + slog.Warn("Cannot find .env file, using system env variables") } adminUser := os.Getenv("ADMIN_USER") adminPass := os.Getenv("ADMIN_PASS") - if adminUser == "" || adminPass == "" { - log.Fatal("No ADMIN_USER or ADMIN_PASS env variable set") + if adminUser == "" { + slog.Error("Missing ADMIN_USER env variable") + os.Exit(1) + } + + if adminPass == "" { + slog.Error("Missing ADMIN_PASS env variable") + os.Exit(1) } db := database() defer db.Close() - app := &App{ - DB: db, - Broker: newBroker(), - FinalizeUpdate: make(chan struct{}, 1), - } + // Create App + app := handlers.NewApp(db, handlers.NewBroker(), make(chan struct{}, 1)) // Create server - srv := server(app) - go startDailyCleanup(app) + srv := NewServer(app, "0.0.0.0:7153", []string{"*"}) - log.Println("Server listening on", srv.Addr) + // Start background cleanup service + go handlers.StartDailyCleanup(app) + + slog.Info("Server started", "address", srv.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal(err) + slog.Error("Cannot start server", "error", err) + os.Exit(1) } } diff --git a/backend/server.go b/backend/server.go new file mode 100644 index 0000000..49e73df --- /dev/null +++ b/backend/server.go @@ -0,0 +1,74 @@ +package main + +import ( + "net/http" + + "menu/handlers" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func NewServer(app *handlers.App, address string, allowedOrigins []string) *http.Server { + r := chi.NewRouter() + + // Middleware + r.Use(middleware.RequestID) + r.Use(middleware.Recoverer) + r.Use(middleware.Logger) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Routes (bind methods to app) + r.Post("/api/register", app.HandleRegister) + r.Post("/api/login", app.HandleLogin) + r.Get("/api/options", app.HandleOptions) + r.Post("/api/selection", app.HandleSaveSelection) + r.Get("/api/selection", app.HandleGetSelection) + r.Get("/api/orders", app.HandleGetOrders) + r.Post("/api/orders", app.HandleAddOrder) + r.Get("/api/orders/stream", app.HandleOrdersStream) + r.Delete("/api/orders/{id}", app.HandleDeleteOrder) + r.Get("/api/me", app.HandleWhoAmI) + r.Get("/api/finalize/time", app.HandleGetFinalizeTime) + + // Only role 0 and 1 allowed + r.Route("/api/admin", func(r chi.Router) { + r.Use(app.RequireLevel(1)) + r.Post("/menu", app.HandleAdminMenu) + r.Get("/menu", app.HandleGetMenuRaw) + r.Get("/users", app.HandleGetUsers) + r.Delete("/users/{id}", app.HandleDeleteUser) + r.Put("/users/{id}", app.HandleUpdateUser) + r.Get("/orders", app.HandleGetAllOrders) + r.Put("/orders/{id}/status", app.HandleAdminUpdateOrderStatus) + r.Delete("/orders/{id}", app.HandleAdminDeleteOrder) + r.Get("/finalize/time", app.HandleGetFinalizeTime) + r.Post("/finalize/time", app.HandleSetFinalizeTime) + r.Post("/finalize/now", app.HandleFinalizeNow) + r.Get("/finalize/last", app.HandleGetLastSummary) + }) + + // Moderators (role <= 50) + r.Route("/api/mod", func(r chi.Router) { + r.Use(app.RequireLevel(50)) + r.Post("/menu", app.HandleAdminMenu) // menu editor + r.Get("/menu", app.HandleGetMenuRaw) + r.Get("/finalize/time", app.HandleGetFinalizeTime) + r.Post("/finalize/time", app.HandleSetFinalizeTime) + r.Post("/finalize/now", app.HandleFinalizeNow) + r.Get("/finalize/last", app.HandleGetLastSummary) + }) + + // Return configured server + return &http.Server{ + Addr: address, + Handler: r, + } +} diff --git a/backend/setup_default_logger.go b/backend/setup_default_logger.go new file mode 100644 index 0000000..701d4ad --- /dev/null +++ b/backend/setup_default_logger.go @@ -0,0 +1,38 @@ +package main + +import ( + "log/slog" + "os" + "path/filepath" + "strings" +) + +func setupDefaultLogger() { + if strings.ToLower(os.Getenv("PROD")) == "true" { + options := &slog.HandlerOptions{ + Level: slog.LevelInfo, + } + customLogger := slog.New(slog.NewTextHandler(os.Stdout, options)) + slog.SetDefault(customLogger) + slog.Info("Production logger initialized", slog.Group("app", "version", Version, "build_time", BuildTime)) + return + } + + sourceFileName := func(groups []string, a slog.Attr) slog.Attr { + // Remove the directory and function from the source's filename. + if a.Key == slog.SourceKey { + source := a.Value.Any().(*slog.Source) + source.File = filepath.Base(source.File) + source.Function = "" + } + return a + } + options := &slog.HandlerOptions{ + Level: slog.LevelDebug, + AddSource: true, + ReplaceAttr: sourceFileName, + } + customLogger := slog.New(slog.NewTextHandler(os.Stdout, options)) + slog.SetDefault(customLogger) + slog.Info("Development logger initialized", slog.Group("app", "version", Version, "build_time", BuildTime)) +}