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 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 diff --git a/backend/.gitignore b/backend/.gitignore index 2052709..698d681 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,6 @@ -go.work* \ No newline at end of file +go.work* +.env +data/ +# misc +.DS_Store +*.pem \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index f744db1..2f7df50 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,17 +1,18 @@ # --- 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 --- 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. diff --git a/backend/app.db b/backend/app.db deleted file mode 100644 index 79d03bf..0000000 Binary files a/backend/app.db and /dev/null differ diff --git a/backend/db.go b/backend/db.go index 99e8b3e..02f13ac 100644 --- a/backend/db.go +++ b/backend/db.go @@ -2,28 +2,83 @@ package main import ( "database/sql" - "log" - "strings" + "embed" + "fmt" + "log/slog" + "os" + "path/filepath" + "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) -// 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) +//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) { + var dbFilePath string + + if dbName == "" { + dbFilePath = filepath.Join(dataDir, "app.db") + } else { + dbFilePath = filepath.Join(dataDir, filepath.Base(dbName)) } - // Seed default menu items if empty - row := db.QueryRow("SELECT COUNT(*) FROM menu_items") + 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 +} + +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) + } + + 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 @@ -59,88 +114,33 @@ func database() *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) + } + slog.Debug("Superadmin password", "password", superadminPassword) } diff --git a/backend/go.mod b/backend/go.mod index 167d824..545d8d4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,21 +1,29 @@ module menu -go 1.25 +go 1.25.3 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/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/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/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/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..0d493ac --- /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 WHERE username != 'superadmin' ORDER BY id") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + users := make([]map[string]any, 0) + 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..d6c1c22 --- /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) *App { + return &App{ + DB: db, + Broker: broker, + FinalizeUpdate: make(chan struct{}, 1), + } +} + +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..222a796 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,1052 +1,69 @@ 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 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), + // Setup data directory + dataDir, err := setupDataDir() + if err != nil { + slog.Error("Cannot set data directory", "dir", os.Getenv("DATA_DIR"), "error", 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) + } + + // Migrate database + MigrateDatabase(dbConnection) + SeedDatabase(dbConnection, adminPass) + updateSuperadminPassword(dbConnection, adminPass) + + // db := database(dbConnection) + defer dbConnection.Close() + + // Create App + app := handlers.NewApp(dbConnection, handlers.NewBroker()) + // Create server - srv := server(app) - go startDailyCleanup(app) + srv := NewServer(app, "0.0.0.0:7153", "*") - 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/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 diff --git a/backend/server.go b/backend/server.go new file mode 100644 index 0000000..c89237b --- /dev/null +++ b/backend/server.go @@ -0,0 +1,82 @@ +package main + +import ( + "net/http" + "strings" + + "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: makeAllowedOrigins(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, + } +} + +func makeAllowedOrigins(origins string) []string { + if origins == "" { + origins = "*" + } + return strings.Split(strings.TrimSpace(origins), ",") +} diff --git a/backend/setup_data_dir.go b/backend/setup_data_dir.go new file mode 100644 index 0000000..40b8048 --- /dev/null +++ b/backend/setup_data_dir.go @@ -0,0 +1,28 @@ +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 !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 { + return "", fmt.Errorf("cannot create '%s' directory: %w", dataDir, err) + } + } + return dataDir, nil +} 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)) +} 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 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"); } }) 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 ( -
+