Refactored project structure

This commit is contained in:
2025-10-16 22:45:31 +02:00
parent cf74cce51c
commit d13ea9fc27
13 changed files with 1156 additions and 1023 deletions

View File

@@ -7,13 +7,13 @@ require github.com/go-chi/chi/v5 v5.2.3
require ( require (
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1
modernc.org/sqlite v1.39.0 modernc.org/sqlite v1.39.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect

377
backend/handlers/api.go Normal file
View File

@@ -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,
})
}

View File

@@ -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)
}

View File

@@ -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: // dont 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)
}

150
backend/handlers/app.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}
}
}()
}

View File

@@ -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)
}
}

41
backend/handlers/jwt.go Normal file
View File

@@ -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
}

45
backend/handlers/types.go Normal file
View File

@@ -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"`
}

File diff suppressed because it is too large Load Diff

74
backend/server.go Normal file
View File

@@ -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,
}
}

View File

@@ -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))
}