From cf74cce51cf6ba3202c650aa24b58c23bbd12967 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Thu, 16 Oct 2025 22:45:02 +0200 Subject: [PATCH 01/19] Do not track .env files --- backend/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.gitignore b/backend/.gitignore index 2052709..e58237b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,2 @@ -go.work* \ No newline at end of file +go.work* +.env \ No newline at end of file -- 2.49.1 From d13ea9fc27d19f01b7c4fadd6e542e48cbf40f0c Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Thu, 16 Oct 2025 22:45:31 +0200 Subject: [PATCH 02/19] Refactored project structure --- backend/go.mod | 2 +- backend/handlers/api.go | 377 +++++++++++ backend/handlers/api_admin.go | 232 +++++++ backend/handlers/api_moderator.go | 68 ++ backend/handlers/app.go | 150 +++++ backend/handlers/broker.go | 45 ++ backend/handlers/daily_cleanup.go | 46 ++ backend/handlers/json_helpers.go | 13 + backend/handlers/jwt.go | 41 ++ backend/handlers/types.go | 45 ++ backend/main.go | 1048 +---------------------------- backend/server.go | 74 ++ backend/setup_default_logger.go | 38 ++ 13 files changed, 1156 insertions(+), 1023 deletions(-) create mode 100644 backend/handlers/api.go create mode 100644 backend/handlers/api_admin.go create mode 100644 backend/handlers/api_moderator.go create mode 100644 backend/handlers/app.go create mode 100644 backend/handlers/broker.go create mode 100644 backend/handlers/daily_cleanup.go create mode 100644 backend/handlers/json_helpers.go create mode 100644 backend/handlers/jwt.go create mode 100644 backend/handlers/types.go create mode 100644 backend/server.go create mode 100644 backend/setup_default_logger.go 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)) +} -- 2.49.1 From fd24695764e8b4aae41f6d7517ef654a2869a0cd Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Thu, 16 Oct 2025 23:48:53 +0200 Subject: [PATCH 03/19] Setup database and Data directory --- backend/db.go | 38 ++++++++++++++++++++++++++++++++------ backend/main.go | 15 ++++++++++++++- backend/setup_data_dir.go | 24 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 backend/setup_data_dir.go diff --git a/backend/db.go b/backend/db.go index 99e8b3e..0bc912a 100644 --- a/backend/db.go +++ b/backend/db.go @@ -2,22 +2,48 @@ package main import ( "database/sql" + "fmt" "log" + "log/slog" + "os" + "path/filepath" "strings" _ "modernc.org/sqlite" ) +// Create new sqlite database +// If filepath parameter is empty +func NewDatabaseConnection(dataDir string, dbName string) (*sql.DB, error) { + var dbFilePath string + + if dbName == "" { + dbFilePath = filepath.Join(dataDir, "app.db") + } else { + dbFilePath = filepath.Join(dataDir, filepath.Base(dbName)) + } + + db, err := sql.Open("sqlite", dbFilePath) + if err != nil { + return nil, fmt.Errorf("database open error: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("database connection error: %w", err) + } + + return db, nil +} + // database // // Manage database connection to ./app.db // Handles CRUD -func database() *sql.DB { - db, err := sql.Open("sqlite", "./app.db") - if err != nil { - log.Fatal(err) +func database(db *sql.DB) *sql.DB { + if db == nil { + slog.Error("No connection to the database!") + os.Exit(1) } - // Seed default menu items if empty row := db.QueryRow("SELECT COUNT(*) FROM menu_items") var count int @@ -65,7 +91,7 @@ func database() *sql.DB { } // Create users table - _, err = db.Exec(` + _, err := db.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, diff --git a/backend/main.go b/backend/main.go index 91fa9a4..2600b26 100644 --- a/backend/main.go +++ b/backend/main.go @@ -36,7 +36,20 @@ func main() { os.Exit(1) } - db := database() + // Setup data directory + dataDir, err := setupDataDir() + if err != nil { + slog.Error("Cannot set data directory: %s; %w", os.Getenv("DATA_DIR"), err) + os.Exit(1) + } + + // Setup database connection + dbConnection, err := NewDatabaseConnection(dataDir, "app.db") + if err != nil { + slog.Error("Databse error", "error", err) + os.Exit(1) + } + db := database(dbConnection) defer db.Close() // Create App diff --git a/backend/setup_data_dir.go b/backend/setup_data_dir.go new file mode 100644 index 0000000..f947a24 --- /dev/null +++ b/backend/setup_data_dir.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func setupDataDir() (string, error) { + var dataDir string + if os.Getenv("DATA_DIR") == "" { + dataDir = filepath.Join(".", "data") + } else { + dataDir = filepath.Join(".", os.Getenv("DATA_DIR")) + } + + if _, err := os.Stat(dataDir); os.IsNotExist(err) { + err := os.Mkdir(dataDir, 0o755) + if err != nil { + return "", fmt.Errorf("cannot create '%s' directory: %w", dataDir, err) + } + } + return dataDir, nil +} -- 2.49.1 From ab0b102a81e0d7399c478bb5b68ac99b5a429241 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Thu, 16 Oct 2025 23:50:29 +0200 Subject: [PATCH 04/19] Refactor handlers.NewApp --- backend/handlers/app.go | 4 ++-- backend/main.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/handlers/app.go b/backend/handlers/app.go index a7708ad..d6c1c22 100644 --- a/backend/handlers/app.go +++ b/backend/handlers/app.go @@ -22,11 +22,11 @@ type App struct { LastSummary *FinalizedSummary } -func NewApp(db *sql.DB, broker *Broker, finalizeUpdate chan struct{}) *App { +func NewApp(db *sql.DB, broker *Broker) *App { return &App{ DB: db, Broker: broker, - FinalizeUpdate: finalizeUpdate, + FinalizeUpdate: make(chan struct{}, 1), } } diff --git a/backend/main.go b/backend/main.go index 2600b26..4716f7a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -53,7 +53,7 @@ func main() { defer db.Close() // Create App - app := handlers.NewApp(db, handlers.NewBroker(), make(chan struct{}, 1)) + app := handlers.NewApp(db, handlers.NewBroker()) // Create server srv := NewServer(app, "0.0.0.0:7153", []string{"*"}) -- 2.49.1 From 1541f1ca852f3e9ae130ef8c427a8e916fe292ae Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Thu, 16 Oct 2025 23:51:20 +0200 Subject: [PATCH 05/19] Exclude data directory --- backend/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.gitignore b/backend/.gitignore index e58237b..dd3965e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ go.work* -.env \ No newline at end of file +.env +data/ \ No newline at end of file -- 2.49.1 From 91c9aba1ab797ad351d4adf60c639cb2daa87956 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Thu, 16 Oct 2025 23:54:59 +0200 Subject: [PATCH 06/19] Update to Go 1.25.3 --- backend/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go.mod b/backend/go.mod index 5f715bf..2454ef1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module menu -go 1.25 +go 1.25.3 require github.com/go-chi/chi/v5 v5.2.3 -- 2.49.1 From 975b72e89ff5a3b9a5bcb98169c90a26b0c0f7f6 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 00:01:16 +0200 Subject: [PATCH 07/19] Refactor allowedOrigins creation --- backend/main.go | 2 +- backend/server.go | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/main.go b/backend/main.go index 4716f7a..fcd701c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -56,7 +56,7 @@ func main() { app := handlers.NewApp(db, handlers.NewBroker()) // Create server - srv := NewServer(app, "0.0.0.0:7153", []string{"*"}) + srv := NewServer(app, "0.0.0.0:7153", "*") // Start background cleanup service go handlers.StartDailyCleanup(app) diff --git a/backend/server.go b/backend/server.go index 49e73df..c89237b 100644 --- a/backend/server.go +++ b/backend/server.go @@ -2,6 +2,7 @@ package main import ( "net/http" + "strings" "menu/handlers" @@ -10,7 +11,7 @@ import ( "github.com/go-chi/cors" ) -func NewServer(app *handlers.App, address string, allowedOrigins []string) *http.Server { +func NewServer(app *handlers.App, address string, allowedOrigins string) *http.Server { r := chi.NewRouter() // Middleware @@ -18,7 +19,7 @@ func NewServer(app *handlers.App, address string, allowedOrigins []string) *http r.Use(middleware.Recoverer) r.Use(middleware.Logger) r.Use(cors.Handler(cors.Options{ - AllowedOrigins: allowedOrigins, + AllowedOrigins: makeAllowedOrigins(allowedOrigins), AllowedMethods: []string{"GET", "POST", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowCredentials: true, @@ -72,3 +73,10 @@ func NewServer(app *handlers.App, address string, allowedOrigins []string) *http Handler: r, } } + +func makeAllowedOrigins(origins string) []string { + if origins == "" { + origins = "*" + } + return strings.Split(strings.TrimSpace(origins), ",") +} -- 2.49.1 From 1a84e44351d9b38687a76f6d4c4429db520438cd Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 00:10:08 +0200 Subject: [PATCH 08/19] Add security to local directory setup --- backend/main.go | 2 +- backend/setup_data_dir.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/main.go b/backend/main.go index fcd701c..e10f01b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -39,7 +39,7 @@ func main() { // Setup data directory dataDir, err := setupDataDir() if err != nil { - slog.Error("Cannot set data directory: %s; %w", os.Getenv("DATA_DIR"), err) + slog.Error("Cannot set data directory", "dir", os.Getenv("DATA_DIR"), "error", err) os.Exit(1) } diff --git a/backend/setup_data_dir.go b/backend/setup_data_dir.go index f947a24..40b8048 100644 --- a/backend/setup_data_dir.go +++ b/backend/setup_data_dir.go @@ -14,6 +14,10 @@ func setupDataDir() (string, error) { dataDir = filepath.Join(".", os.Getenv("DATA_DIR")) } + if !filepath.IsLocal(dataDir) { + return "", fmt.Errorf("directory '%s' is not valid or not local", dataDir) + } + if _, err := os.Stat(dataDir); os.IsNotExist(err) { err := os.Mkdir(dataDir, 0o755) if err != nil { -- 2.49.1 From 03f92076b5480f194a3be1f77249b2f87d3385c2 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:39:32 +0200 Subject: [PATCH 09/19] Top level gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file -- 2.49.1 From 7849a77acc90895661f800da6d8808cf5f4e8d3e Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:39:44 +0200 Subject: [PATCH 10/19] Add .DS_Store to gitingore --- backend/.gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/.gitignore b/backend/.gitignore index dd3965e..698d681 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,6 @@ go.work* .env -data/ \ No newline at end of file +data/ +# misc +.DS_Store +*.pem \ No newline at end of file -- 2.49.1 From 390410020fd6aa2464fc6ad36cf89b026803065d Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:40:27 +0200 Subject: [PATCH 11/19] Remove app.db --- backend/app.db | Bin 49152 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 backend/app.db diff --git a/backend/app.db b/backend/app.db deleted file mode 100644 index 79d03bfa53f61376f09f2176e3a2ae27af42e36d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49152 zcmeI5eN0=|6~Ldd!5`RuNeOjHn()#jO(-GwGr&u_6o?@ZASpN{S(dVg{QzTQllKe> zv|Dx4^`HGQY2EjSCZ+qQ-PUSpv{b4lZIcygQZ;GXAJM*~bz4`dleTHv)J^kZ^+5COS75B9&m*HU~ zfCP{L5StG_Mo-7t6^q+~G8kvBL#={elgR~&MCM7MEh!xIE zj68lO%t|avs(LA{#7Y#i+Ybs#Iw|XN@+u3*!$bXN#v+2hgFIlhdUo$Nk*Ap6OLA&u z1_tm;YT=#DU_s${on}O(j69!Ka&+^mF#XG|1G_s&s>$kUYcoCD!&C$NfT{M~YEVf# z#?;v;)XGe$GHQBdnQ^4QEJ^W{5h^PZkCixs9*rt8SQwjuX%CB^yoC<%f|`*sE2{Ch z?u7kPG!tKy_jg>`*2sMEEEH#9It9U-f7fI%yl}iuUr_UBidf}!rFK*?+OAf# zy1+{?R8oxwlQ48@GCVRoL7SFvAg4=*Fc}^SPlhK3!&3r1rXGmLIzXQe_aC??S<|w9 z7)`6&-sBnFY2xCkn0#5in1tY;kybKk_WMk}gP8H<-#PeJj?P@U4oj2gQBSoCnFTXG z_M;I>vrA4lGbU(H5G8_OP`4dx&e?I3Ow>vnR@H`gJ!Li#i|yB!C2v01`j~NB{{S0VIF~kN^_6>j*d<7S7XhG!|bK zBsB)=jrm$e(h z&uTHzTHFm#VT*Q-R=`^txwaNlQck5;VW&~5!NLC+=Krr*_&@P)@UQW=;OmY2*ImaK z6+!|?00|%gB!C2v01`j~NB{{S0VMDdBw&Y+>e*s}1C&2nyihQ^z921Lw%>2|nfjB` zqPh@UkZ0vthq=u(xEP;{uiMP6Cb|m1^S|e8FtK;9t8Q8yTRGU$NDFFneE49Wv(Pha z(P1kH+R7FR8sME3E~NYaP5d`F_=hhffCP{L5yKt%QXwIKB02#_r$=1J2GdGvE-58sRhV5##j;mt zrIes%f0)b&Q8lh4;G$$Q8BaleLVn(NTSzelZe^6$BOU2nR+0H<3Ad|-S~v%FP6)e`^-1Z>*nr;w;I0I&}Fik-r!bE&(Z2?y@-qS z+Tc-$=?ll&*m6W9dsRuKQ)_8en3U#MlJcsoPG+yBGU-f47)~$EN&=;#9~y*&ic7Qb z0K||IS5;w3vr;%IEh|vPXnKBOMajUs?3dv&iA+XLG5|hrmp2gc`9=R>_@@ETl6iiT z9H#mN_8r^7mQZTyGpIv94Qi*s4U_S8RhQt4_(Ed9F9y7tgoU`8fjp|fV+V-n>?}8V zko19c*Pea3Q{eDeIwcGw<&}yO@;2n1sM#<>dZ-N`U`JJ^C(=nZBSVtZsddOvni*E& z$)xb{?2Tkh&Av2WVnRUl`VA(;Nr+15K2k%%fHbRs2UTH^Mrig%V(8{IbzN2GK|T2*(RRhbW})AIL<-X23`QPK^Sh4$o~Jd{qbenk>aD%qEoSko*D z&<(lQ-y`~sDl6m=l`_4d23M;CsBRjx^N=xq=SUahbRpNu%Q)5DoA@H$J`t?*8N+0h z9Lzg}bth$ls%T9s3+jDyuHzUdovbow*Oe=))QX|64|pTK0PToEL4$)8^2mpTJ}1;K z27!n%s#2tb3LUA{ISt0=q03)emZPeiT9ctSPe^bBgQ#0vl#(^91c8UekWpZQ9AE{kt6Wvk@r6XMvA3Ea58JplS2vK-mhj7h zrv?e180;1O#tP@iKB`;yor!u~a}eas-9vMM`Xt#)1?g?NXxuWOk6WhLxTVG|R2L9{ zQHs$Lh3o+V?bFaTG0I`(=I84eD%7|U=|X8wWo$0vFxd%!naHN+srCxDpq}=&)x~t-#+G@dcsH!Hv`qm+M3T<+~^`P5Dx3 zx-qr-*jjx&SW1NlL_dspjD0-eNIR8&E?iA|VZ57fo^!VUqT9&idV9YutEoSD^$+hhDIxG*sox|8zlH@@f*V-2V z*V9i5Bk&Bex*jhK_KXdu&>%P9Y#2a$#ZYbrk!$?(WCv@T&`j8Pt-iKdsjsDB$;og;+P36A(a@#6@7i8&)DUhAP>-vbi-rcobFIn58Me29l#mlp@9ce ziz*#J(NM0UioJBg=nsfKV{~02_uIJk9@^JW#gnVDG96DH&)!hx0e~E*=FdXo-RLZ? zhGef8G`jz3(n=e2RKGt~nyb~}m@0QuKQr9d3zKC7&?m_@@J&b`Ygdw97KzpEqaHIh zqy%Z99;5e%%3b#6ZV=2IFC7o&%z^o6GwE~jN4fi)w>W;4-^H8VZ@XV}zu;bXhuy8N z_g%krz3lq5Ys_`n)#`lTd5e!aZ|Pq@tu?>Lb4UOQAOR$R1dsp{Kmter2_S)*1iTMe zxYi5w&(>)Z_uLAPkkCHVTP%F22kSKKLp|3iZhLFj)*iUd*$3ZR=^q13EG{JW2I03b zt*X|djVA!5I%%nrIbgdVP8?yHR9-XO=7R$JXo2FHo!1LTiwwB^r;=UWaK$wJtA!g{ zf$BPxFTSCr)m>dsypPsid3DKi5YBhf^CipGhdSZJIeKDa3v_`;pnQ~;uVdc*a0gs8 zLNBU4p=~(;<@T_0g^xwM_QOF2ZbJ*0J06DO0a}M%ys$viwhvBr)01WM_6PUEsSu5- z{Q6|^r(paSIkB~mo~^vN2InC>)vZUMl6G2bVogf0MUe-0=j*Msd;qN^32_M*jKUhA z6c<1*b{U+Qg1Cee*{?4yWUs<jn`pF7X- z&+|U_tK4<&ynEjDuIoA14(Iorea){nA97rC9Jl||{)~N>?FY7)t5=lC3OkC`R)`lcob6-WMGa?LNpiVbOSqfDsU?YB8zmz&w-n7^#EdE1^H@=R$T%AmVJ*j( z&mN?W9_+7hdzQ#3tq>wYOXXX#H8rK5*4MxD$&9Nf1>BH~9`b+F@DbtxDsi+&Vm1+*_LF>?tx#Yp2<#swUa3V-sv`>5!?}nET4{6Yc_yM93k0 zCvD4x#xx02Pmbh6qxQbsYH<>POq`|E&XPgq6R5c=wIZL?thywvvh#87EFcL$Ch0pG z+E0@qsxs88wUbI=q<1@o&BS%h&~kAG#L9AMo+rl{v3#6W>h+3<)rceJkAN{<=~>cm z@W*Xxui+2q(TsUl-TmA-Er_7!gg;|`2ZBfo&rvb}I&Y%m0stYgB#$#`AdgW3^ghNS zvJwH^y&t$QhOR0g2Fva;XUVZU7sM~);~xYB}elPV~ERCs1{Zr2GZ^sZ-MH$~?=1Nk9S70unk6^xqQ0PV@(6vv!=eIm#rut>&XY%J)il4QR&|+ToAX`3 LSDM;(ZEfv;T9*BV -- 2.49.1 From d9bbede3285787abddb54f4937c212f48515dd43 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:40:47 +0200 Subject: [PATCH 12/19] Update node version to 25 --- feedmee/Dockerfile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/feedmee/Dockerfile b/feedmee/Dockerfile index ec8b0b9..346cc3e 100644 --- a/feedmee/Dockerfile +++ b/feedmee/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker.io/docker/dockerfile:1 -FROM node:20-alpine AS base +FROM node:25-alpine AS base # 1. Install dependencies only when needed FROM base AS deps @@ -43,11 +43,8 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - USER nextjs - EXPOSE 3000 - ENV PORT=3000 CMD HOSTNAME="0.0.0.0" node server.js \ No newline at end of file -- 2.49.1 From 176032e54b35dda08fe4026a2dcbbb0fcf3f6fa1 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:41:10 +0200 Subject: [PATCH 13/19] Change container workdir from /root to /app --- backend/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index f744db1..7bd5b43 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,9 +9,10 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags="-s -w" -o backend . +# --- DEPLOY STAGE --- FROM alpine:latest -WORKDIR /root/ +WORKDIR /app/ COPY --from=builder /app/backend . # Ha a Go alkalmazásodnak szüksége van SSL tanúsítványokra (pl. HTTPS hívásokhoz külső API-k felé), akkor az Alpine image-be telepíteni kell a ca-certificates csomagot. -- 2.49.1 From 560693633e082252f1e0477eb065453bb4dbb699 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:41:58 +0200 Subject: [PATCH 14/19] Refactor backend database connection, migration, seed --- backend/db.go | 149 +++++++----------- backend/go.mod | 8 + backend/go.sum | 12 +- backend/main.go | 18 +-- ...0251017064937_00001_create_users_table.sql | 14 ++ ...1017065315_00002_create_settings_table.sql | 14 ++ ...17065447_00003_create_menu_items_table.sql | 13 ++ ...17065539_00004_create_selections_table.sql | 17 ++ ...251017065648_00005_create_orders_table.sql | 17 ++ 9 files changed, 163 insertions(+), 99 deletions(-) create mode 100644 backend/migrations/20251017064937_00001_create_users_table.sql create mode 100644 backend/migrations/20251017065315_00002_create_settings_table.sql create mode 100644 backend/migrations/20251017065447_00003_create_menu_items_table.sql create mode 100644 backend/migrations/20251017065539_00004_create_selections_table.sql create mode 100644 backend/migrations/20251017065648_00005_create_orders_table.sql diff --git a/backend/db.go b/backend/db.go index 0bc912a..d121de6 100644 --- a/backend/db.go +++ b/backend/db.go @@ -2,16 +2,19 @@ package main import ( "database/sql" + "embed" "fmt" - "log" "log/slog" "os" "path/filepath" - "strings" + "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) +//go:embed migrations/*.sql +var embedMigrations embed.FS + // Create new sqlite database // If filepath parameter is empty func NewDatabaseConnection(dataDir string, dbName string) (*sql.DB, error) { @@ -35,21 +38,47 @@ func NewDatabaseConnection(dataDir string, dbName string) (*sql.DB, error) { return db, nil } -// database -// -// Manage database connection to ./app.db -// Handles CRUD -func database(db *sql.DB) *sql.DB { - if db == nil { - slog.Error("No connection to the database!") +func MigrateDatabase(db *sql.DB) { + dialect := "sqlite3" + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect(dialect); err != nil { + slog.Error("Database dialect error", "dialect", dialect, "error", err) os.Exit(1) } - // Seed default menu items if empty - row := db.QueryRow("SELECT COUNT(*) FROM menu_items") + + if err := goose.Up(db, "migrations"); err != nil { + slog.Error("Database migration error", "error", err) + os.Exit(1) + } + + migratedVersion, err := goose.GetDBVersion(db) + if err != nil { + slog.Error("Datatabase migration version check error", "error", err) + os.Exit(1) + } + + slog.Info("Database up to date", "version", migratedVersion) +} + +func SeedDatabase(db *sql.DB, superadminPassword string) error { + seededValue := "false" + row := db.QueryRow("SELECT value FROM settings WHERE key='seeded';") + _ = row.Scan(&seededValue) + + if seededValue == "true" { + slog.Info("Database already seeded with default data, skipping") + return nil + } + + slog.Info("Database empty, seeding with default values") + + row = db.QueryRow("SELECT COUNT(*) FROM menu_items") var count int _ = row.Scan(&count) if count == 0 { - log.Println("Seeding default menu_items…") + slog.Info("Seeding default menu_items") + defaults := []struct { category string name string @@ -85,88 +114,32 @@ func database(db *sql.DB) *sql.DB { for _, d := range defaults { _, err := db.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", d.category, d.name) if err != nil { - log.Println("Error seeding menu item:", err) + return fmt.Errorf("Error seeding menu_items table: %w", err) } } + } else { + slog.Info("Table menu_items already seeded, skipping") } - // Create users table - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - role INTEGER NOT NULL DEFAULT 2 - ) - `) + _, err := db.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES ('seeded', 'true')`) if err != nil { - log.Fatal(err) + return fmt.Errorf("Error saving setting table: %w", err) } - // Create settings table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - `) - if err != nil { - log.Fatal(err) - } + updateSuperadminPassword(db, superadminPassword) - // Seed default finalize_time if missing - _, err = db.Exec(`INSERT OR IGNORE INTO settings (key, value) VALUES ('finalize_time', '10:30')`) - if err != nil { - log.Fatal(err) - } - - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS menu_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category TEXT NOT NULL, - name TEXT NOT NULL - ) - `) - if err != nil { - log.Fatal(err) - } - - // Create selections table (latest choice per user) - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS selections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - main TEXT NOT NULL, - side TEXT NOT NULL, - soup TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `) - if err != nil { - log.Fatal(err) - } - - // Create orders table (all placed orders for history + realtime updates) - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - soup TEXT, - main TEXT NOT NULL, - side TEXT NOT NULL, - created_at TEXT NOT NULL - ) - `) - if err != nil { - log.Fatal(err) - } - - // Add status column if it doesn't exist - _, err = db.Exec(`ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`) - if err != nil && !strings.Contains(err.Error(), "duplicate column name") { - log.Fatal(err) - } - - return db + return nil +} + +func updateSuperadminPassword(db *sql.DB, superadminPassword string) { + _, err := db.Exec(`INSERT OR REPLACE INTO users (id, username, password, role) + VALUES ( + 1, + 'superadmin', + ?, + COALESCE((SELECT role FROM users WHERE id = 1), '0') + )`, superadminPassword) + if err != nil { + slog.Error("Error setting superadmin password", "error", err) + } } diff --git a/backend/go.mod b/backend/go.mod index 2454ef1..545d8d4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,11 +11,19 @@ require ( modernc.org/sqlite v1.39.0 ) +require ( + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.16.0 // indirect +) + require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pressly/goose/v3 v3.26.0 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sys v0.34.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 41e20da..ca8a701 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -14,16 +14,24 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/backend/main.go b/backend/main.go index e10f01b..222a796 100644 --- a/backend/main.go +++ b/backend/main.go @@ -23,14 +23,8 @@ func main() { slog.Warn("Cannot find .env file, using system env variables") } - adminUser := os.Getenv("ADMIN_USER") adminPass := os.Getenv("ADMIN_PASS") - if adminUser == "" { - slog.Error("Missing ADMIN_USER env variable") - os.Exit(1) - } - if adminPass == "" { slog.Error("Missing ADMIN_PASS env variable") os.Exit(1) @@ -49,11 +43,17 @@ func main() { slog.Error("Databse error", "error", err) os.Exit(1) } - db := database(dbConnection) - defer db.Close() + + // Migrate database + MigrateDatabase(dbConnection) + SeedDatabase(dbConnection, adminPass) + updateSuperadminPassword(dbConnection, adminPass) + + // db := database(dbConnection) + defer dbConnection.Close() // Create App - app := handlers.NewApp(db, handlers.NewBroker()) + app := handlers.NewApp(dbConnection, handlers.NewBroker()) // Create server srv := NewServer(app, "0.0.0.0:7153", "*") diff --git a/backend/migrations/20251017064937_00001_create_users_table.sql b/backend/migrations/20251017064937_00001_create_users_table.sql new file mode 100644 index 0000000..77853d9 --- /dev/null +++ b/backend/migrations/20251017064937_00001_create_users_table.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + role INTEGER NOT NULL DEFAULT 2 +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS users; +-- +goose StatementEnd diff --git a/backend/migrations/20251017065315_00002_create_settings_table.sql b/backend/migrations/20251017065315_00002_create_settings_table.sql new file mode 100644 index 0000000..5af0581 --- /dev/null +++ b/backend/migrations/20251017065315_00002_create_settings_table.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +INSERT INTO settings (key, value) VALUES ('finalize_time', '10:30'); +INSERT INTO settings (key, value) VALUES ('seeded', 'false'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS settings; +-- +goose StatementEnd diff --git a/backend/migrations/20251017065447_00003_create_menu_items_table.sql b/backend/migrations/20251017065447_00003_create_menu_items_table.sql new file mode 100644 index 0000000..1f55220 --- /dev/null +++ b/backend/migrations/20251017065447_00003_create_menu_items_table.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS menu_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, + name TEXT NOT NULL +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS menu_items; +-- +goose StatementEnd diff --git a/backend/migrations/20251017065539_00004_create_selections_table.sql b/backend/migrations/20251017065539_00004_create_selections_table.sql new file mode 100644 index 0000000..93e4797 --- /dev/null +++ b/backend/migrations/20251017065539_00004_create_selections_table.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS selections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + main TEXT NOT NULL, + side TEXT NOT NULL, + soup TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS selections; +-- +goose StatementEnd diff --git a/backend/migrations/20251017065648_00005_create_orders_table.sql b/backend/migrations/20251017065648_00005_create_orders_table.sql new file mode 100644 index 0000000..1f60989 --- /dev/null +++ b/backend/migrations/20251017065648_00005_create_orders_table.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + soup TEXT, + main TEXT NOT NULL, + side TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS orders; +-- +goose StatementEnd -- 2.49.1 From d936175de80ec4da8f6597aab964256f7dae5f2a Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:42:14 +0200 Subject: [PATCH 15/19] Fix User listing in admin page --- backend/handlers/api_admin.go | 4 ++-- feedmee/src/app/admin/page.tsx | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/handlers/api_admin.go b/backend/handlers/api_admin.go index 7b592f5..0d493ac 100644 --- a/backend/handlers/api_admin.go +++ b/backend/handlers/api_admin.go @@ -114,14 +114,14 @@ func (app *App) HandleAdminDeleteOrder(w http.ResponseWriter, r *http.Request) { } func (app *App) HandleGetUsers(w http.ResponseWriter, r *http.Request) { - rows, err := app.DB.Query("SELECT id, username, role FROM users ORDER BY id") + rows, err := app.DB.Query("SELECT id, username, role FROM users WHERE username != 'superadmin' ORDER BY id") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() - var users []map[string]any + users := make([]map[string]any, 0) for rows.Next() { var id, role int var username string diff --git a/feedmee/src/app/admin/page.tsx b/feedmee/src/app/admin/page.tsx index e593e36..1149c21 100644 --- a/feedmee/src/app/admin/page.tsx +++ b/feedmee/src/app/admin/page.tsx @@ -71,9 +71,14 @@ export default function AdminPage() { fetch(`${API_URL}/api/me`, { headers: { Authorization: `Bearer ${token}` }, }) - .then((res) => res.json()) - .then((data) => { - if (!data.role || data.role > 1) { + .then((res) => { + console.log(res); + return res.json(); + }) + .then((data: { role?: number; username?: string }) => { + if (data.role == undefined || data.role > 1) { + console.log(data); + console.log("Not admin, redirecting"); router.push("/landing"); } }) -- 2.49.1 From 232e553c026e3ef4935683b0fb9b3e6aecf3b662 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:42:38 +0200 Subject: [PATCH 16/19] Remove Geist and GeistMono font --- feedmee/src/app/layout.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/feedmee/src/app/layout.tsx b/feedmee/src/app/layout.tsx index de11388..3677623 100644 --- a/feedmee/src/app/layout.tsx +++ b/feedmee/src/app/layout.tsx @@ -1,17 +1,5 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata = { title: "FeedMe", description: "Food ordering app", @@ -28,9 +16,7 @@ export default function RootLayout({ }>) { return ( - +
{children}
-- 2.49.1 From b8e5a2841f55012db1d4b6b1eea3e44c489a8b16 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:44:27 +0200 Subject: [PATCH 17/19] Add CI to galt branch --- .../workflows/galt-test-frontend-build.yaml | 33 +++++++++++++++++++ .gitea/workflows/gat-test-backend-build.yaml | 33 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .gitea/workflows/galt-test-frontend-build.yaml create mode 100644 .gitea/workflows/gat-test-backend-build.yaml diff --git a/.gitea/workflows/galt-test-frontend-build.yaml b/.gitea/workflows/galt-test-frontend-build.yaml new file mode 100644 index 0000000..b83c161 --- /dev/null +++ b/.gitea/workflows/galt-test-frontend-build.yaml @@ -0,0 +1,33 @@ +name: Build Feedmee Next.js Frontend App + +defaults: + run: + working-directory: ./feedmee + +on: + push: + branches: + - galt + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{vars.REGISTRY_URL}} + username: ${{vars.ACTOR}} + password: ${{secrets.TOKEN}} + + - name: Build Docker image + run: | + docker build -f Dockerfile -t ${{vars.REGISTRY_URL}}/${{github.repository}}-frontend-galt-test:latest . + + - name: Push Docker image + run: | + docker push ${{vars.REGISTRY_URL}}/${{github.repository}}-frontend-galt-test:latest diff --git a/.gitea/workflows/gat-test-backend-build.yaml b/.gitea/workflows/gat-test-backend-build.yaml new file mode 100644 index 0000000..63c751b --- /dev/null +++ b/.gitea/workflows/gat-test-backend-build.yaml @@ -0,0 +1,33 @@ +name: Build Feedmee Go Backend App + +defaults: + run: + working-directory: ./backend + +on: + push: + branches: + - galt + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{vars.REGISTRY_URL}} + username: ${{vars.ACTOR}} + password: ${{secrets.TOKEN}} + + - name: Build Docker image + run: | + docker build -f Dockerfile -t ${{vars.REGISTRY_URL}}/${{github.repository}}-backend-galt-test:latest . + + - name: Push Docker image + run: | + docker push ${{vars.REGISTRY_URL}}/${{github.repository}}-backend-galt-test:latest -- 2.49.1 From 37d2ce99bf18bb3d95692d57a7d03828da1fb0f0 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 12:50:05 +0200 Subject: [PATCH 18/19] Fix version and BuildTime injection --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 7bd5b43..2f7df50 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,12 @@ # --- BUILD STAGE --- -FROM golang:1.25.0 AS builder +FROM golang:1.25.3 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . # Fontos: CGO_ENABLED=0 a statikusan linkelt binárisért, ami kompatibilis lesz az Alpine alapú futtatási környezettel. -RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags="-s -w" -o backend . +RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags="-s -w -X 'main.BuildTime=$(date)'" -o backend . # --- DEPLOY STAGE --- -- 2.49.1 From dff9d56e1b0f52757faaf9ac3d4ef3a300c127a1 Mon Sep 17 00:00:00 2001 From: Tamas Gal Date: Fri, 17 Oct 2025 13:03:08 +0200 Subject: [PATCH 19/19] Debug superadmin password --- backend/db.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/db.go b/backend/db.go index d121de6..02f13ac 100644 --- a/backend/db.go +++ b/backend/db.go @@ -142,4 +142,5 @@ func updateSuperadminPassword(db *sql.DB, superadminPassword string) { if err != nil { slog.Error("Error setting superadmin password", "error", err) } + slog.Debug("Superadmin password", "password", superadminPassword) } -- 2.49.1