fix: fix menu generation errors and show planned meals on home screen

Backend fixes:
- migration 003: add 'menu' value to recipe_source enum (was causing SQLSTATE 22P02)
- migration 004: rename recipe_products→recipe_ingredients, product_id→ingredient_id (was causing SQLSTATE 42P01)
- dish/repository.go: fix INSERT INTO tags using $1/$1 for two columns → $1/$2 (was causing SQLSTATE 42P08)
- home/handler.go: replace non-existent saved_recipes table with correct joins (recipes→dishes→dish_translations, user_saved_recipes) so today's plan and recommendations load correctly
- reqlog: new slog.Handler wrapper that adds request_id and stack trace to ERROR-level logs
- all handlers: slog.Error→slog.ErrorContext so error logs include request context; writeError includes request_id in response body

Client:
- home_screen.dart: extend home screen to future dates, show planned meals as ghost entries
- l10n: add new localisation keys for home screen date navigation and planned meal UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-22 00:35:11 +02:00
parent 9306d59d36
commit 5096df2102
49 changed files with 824 additions and 299 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ client/ios/Runner/GoogleService-Info.plist
# Backend # Backend
backend/.env backend/.env
backend/server
backend/importoff backend/importoff
backend/openfoodfacts-products.jsonl backend/openfoodfacts-products.jsonl
backend/openfoodfacts-products.jsonl.gz backend/openfoodfacts-products.jsonl.gz

View File

@@ -13,12 +13,14 @@ import (
"github.com/food-ai/backend/internal/infra/config" "github.com/food-ai/backend/internal/infra/config"
"github.com/food-ai/backend/internal/infra/database" "github.com/food-ai/backend/internal/infra/database"
"github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/infra/reqlog"
) )
func main() { func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, Level: slog.LevelInfo,
})) })
logger := slog.New(reqlog.New(baseHandler))
slog.SetDefault(logger) slog.SetDefault(logger)
if runError := run(); runError != nil { if runError := run(); runError != nil {

View File

@@ -30,18 +30,18 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req loginRequest var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body") writeErrorJSON(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
if req.FirebaseToken == "" { if req.FirebaseToken == "" {
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required") writeErrorJSON(w, r, http.StatusBadRequest, "firebase_token is required")
return return
} }
resp, err := h.service.Login(r.Context(), req.FirebaseToken) resp, err := h.service.Login(r.Context(), req.FirebaseToken)
if err != nil { if err != nil {
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed") writeErrorJSON(w, r, http.StatusUnauthorized, "authentication failed")
return return
} }
@@ -52,18 +52,18 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req refreshRequest var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body") writeErrorJSON(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
if req.RefreshToken == "" { if req.RefreshToken == "" {
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required") writeErrorJSON(w, r, http.StatusBadRequest, "refresh_token is required")
return return
} }
resp, err := h.service.Refresh(r.Context(), req.RefreshToken) resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
if err != nil { if err != nil {
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token") writeErrorJSON(w, r, http.StatusUnauthorized, "invalid refresh token")
return return
} }
@@ -73,12 +73,12 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
if err := h.service.Logout(r.Context(), userID); err != nil { if err := h.service.Logout(r.Context(), userID); err != nil {
writeErrorJSON(w, http.StatusInternalServerError, "logout failed") writeErrorJSON(w, r, http.StatusInternalServerError, "logout failed")
return return
} }
@@ -86,14 +86,18 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(w http.ResponseWriter, status int, msg string) { func writeErrorJSON(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { if encodeErr := json.NewEncoder(w).Encode(errorResponse{
slog.Error("failed to write error response", "err", err) Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
}); encodeErr != nil {
slog.ErrorContext(r.Context(), "failed to write error response", "err", encodeErr)
} }
} }

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -26,8 +27,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $1 LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $1
ORDER BY c.sort_order`, lang) ORDER BY c.sort_order`, lang)
if queryError != nil { if queryError != nil {
slog.Error("list cuisines", "err", queryError) slog.ErrorContext(request.Context(), "list cuisines", "err", queryError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
return return
} }
defer rows.Close() defer rows.Close()
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var item cuisineItem var item cuisineItem
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil { if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
slog.Error("scan cuisine row", "err", scanError) slog.ErrorContext(request.Context(), "scan cuisine row", "err", scanError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
return return
} }
items = append(items, item) items = append(items, item)
} }
if rowsError := rows.Err(); rowsError != nil { if rowsError := rows.Err(); rowsError != nil {
slog.Error("iterate cuisine rows", "err", rowsError) slog.ErrorContext(request.Context(), "iterate cuisine rows", "err", rowsError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
return return
} }
@@ -53,8 +54,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
} }
} }
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message string) { func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(map[string]string{"error": message}) _ = json.NewEncoder(responseWriter).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(request.Context())})
} }

View File

@@ -45,20 +45,20 @@ func NewHandler(repo DiaryRepository, dishRepo DishRepository, recipeRepo Recipe
func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
date := r.URL.Query().Get("date") date := r.URL.Query().Get("date")
if date == "" { if date == "" {
writeError(w, http.StatusBadRequest, "date query parameter required (YYYY-MM-DD)") writeError(w, r, http.StatusBadRequest, "date query parameter required (YYYY-MM-DD)")
return return
} }
entries, listError := h.repo.ListByDate(r.Context(), userID, date) entries, listError := h.repo.ListByDate(r.Context(), userID, date)
if listError != nil { if listError != nil {
slog.Error("list diary by date", "err", listError) slog.ErrorContext(r.Context(), "list diary by date", "err", listError)
writeError(w, http.StatusInternalServerError, "failed to load diary") writeError(w, r, http.StatusInternalServerError, "failed to load diary")
return return
} }
if entries == nil { if entries == nil {
@@ -71,21 +71,21 @@ func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
var req CreateRequest var req CreateRequest
if decodeError := json.NewDecoder(r.Body).Decode(&req); decodeError != nil { if decodeError := json.NewDecoder(r.Body).Decode(&req); decodeError != nil {
writeError(w, http.StatusBadRequest, "invalid request body") writeError(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
if req.Date == "" || req.MealType == "" { if req.Date == "" || req.MealType == "" {
writeError(w, http.StatusBadRequest, "date and meal_type are required") writeError(w, r, http.StatusBadRequest, "date and meal_type are required")
return return
} }
if req.DishID == nil && req.ProductID == nil && req.Name == "" { if req.DishID == nil && req.ProductID == nil && req.Name == "" {
writeError(w, http.StatusBadRequest, "dish_id, product_id, or name is required") writeError(w, r, http.StatusBadRequest, "dish_id, product_id, or name is required")
return return
} }
@@ -94,8 +94,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
if req.DishID == nil { if req.DishID == nil {
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name) dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
if resolveError != nil { if resolveError != nil {
slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError) slog.ErrorContext(r.Context(), "resolve dish for diary entry", "name", req.Name, "err", resolveError)
writeError(w, http.StatusInternalServerError, "failed to resolve dish") writeError(w, r, http.StatusInternalServerError, "failed to resolve dish")
return return
} }
req.DishID = &dishID req.DishID = &dishID
@@ -104,8 +104,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
if req.RecipeID == nil { if req.RecipeID == nil {
recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0) recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0)
if recipeError != nil { if recipeError != nil {
slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) slog.ErrorContext(r.Context(), "find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError)
writeError(w, http.StatusInternalServerError, "failed to resolve recipe") writeError(w, r, http.StatusInternalServerError, "failed to resolve recipe")
return return
} }
req.RecipeID = &recipeID req.RecipeID = &recipeID
@@ -114,8 +114,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
entry, createError := h.repo.Create(r.Context(), userID, req) entry, createError := h.repo.Create(r.Context(), userID, req)
if createError != nil { if createError != nil {
slog.Error("create diary entry", "err", createError) slog.ErrorContext(r.Context(), "create diary entry", "err", createError)
writeError(w, http.StatusInternalServerError, "failed to create diary entry") writeError(w, r, http.StatusInternalServerError, "failed to create diary entry")
return return
} }
writeJSON(w, http.StatusCreated, entry) writeJSON(w, http.StatusCreated, entry)
@@ -125,7 +125,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
limit := 10 limit := 10
@@ -136,8 +136,8 @@ func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) {
} }
items, queryError := h.repo.GetRecent(r.Context(), userID, limit) items, queryError := h.repo.GetRecent(r.Context(), userID, limit)
if queryError != nil { if queryError != nil {
slog.Error("get recent diary items", "err", queryError) slog.ErrorContext(r.Context(), "get recent diary items", "err", queryError)
writeError(w, http.StatusInternalServerError, "failed to get recent items") writeError(w, r, http.StatusInternalServerError, "failed to get recent items")
return return
} }
if items == nil { if items == nil {
@@ -150,31 +150,35 @@ func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil { if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil {
if deleteError == ErrNotFound { if deleteError == ErrNotFound {
writeError(w, http.StatusNotFound, "diary entry not found") writeError(w, r, http.StatusNotFound, "diary entry not found")
return return
} }
slog.Error("delete diary entry", "err", deleteError) slog.ErrorContext(r.Context(), "delete diary entry", "err", deleteError)
writeError(w, http.StatusInternalServerError, "failed to delete diary entry") writeError(w, r, http.StatusInternalServerError, "failed to delete diary entry")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeError(w http.ResponseWriter, status int, msg string) { func writeError(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(w).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {

View File

@@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/food-ai/backend/internal/infra/middleware"
) )
// Handler handles HTTP requests for dishes. // Handler handles HTTP requests for dishes.
@@ -34,8 +35,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
} }
results, searchError := h.repo.Search(r.Context(), query, limit) results, searchError := h.repo.Search(r.Context(), query, limit)
if searchError != nil { if searchError != nil {
slog.Error("search dishes", "err", searchError) slog.ErrorContext(r.Context(), "search dishes", "err", searchError)
writeError(w, http.StatusInternalServerError, "failed to search dishes") writeError(w, r, http.StatusInternalServerError, "failed to search dishes")
return return
} }
if results == nil { if results == nil {
@@ -48,8 +49,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
dishes, err := h.repo.List(r.Context()) dishes, err := h.repo.List(r.Context())
if err != nil { if err != nil {
slog.Error("list dishes", "err", err) slog.ErrorContext(r.Context(), "list dishes", "err", err)
writeError(w, http.StatusInternalServerError, "failed to list dishes") writeError(w, r, http.StatusInternalServerError, "failed to list dishes")
return return
} }
if dishes == nil { if dishes == nil {
@@ -63,12 +64,12 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
dish, err := h.repo.GetByID(r.Context(), id) dish, err := h.repo.GetByID(r.Context(), id)
if err != nil { if err != nil {
slog.Error("get dish", "id", id, "err", err) slog.ErrorContext(r.Context(), "get dish", "id", id, "err", err)
writeError(w, http.StatusInternalServerError, "failed to get dish") writeError(w, r, http.StatusInternalServerError, "failed to get dish")
return return
} }
if dish == nil { if dish == nil {
writeError(w, http.StatusNotFound, "dish not found") writeError(w, r, http.StatusNotFound, "dish not found")
return return
} }
writeJSON(w, http.StatusOK, dish) writeJSON(w, http.StatusOK, dish)
@@ -77,13 +78,17 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
// --- helpers --- // --- helpers ---
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeError(w http.ResponseWriter, status int, msg string) { func writeError(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(w).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {

View File

@@ -312,13 +312,20 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st
return "", fmt.Errorf("insert dish: %w", err) return "", fmt.Errorf("insert dish: %w", err)
} }
// Insert tags. // Insert tags — upsert into tags first so the FK constraint is satisfied
// even when the AI generates a tag slug that does not exist yet.
for _, slug := range req.Tags { for _, slug := range req.Tags {
if _, err := tx.Exec(ctx, if _, upsertErr := tx.Exec(ctx,
`INSERT INTO tags (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING`,
slug, slug,
); upsertErr != nil {
return "", fmt.Errorf("upsert tag %s: %w", slug, upsertErr)
}
if _, insertErr := tx.Exec(ctx,
`INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`, `INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
dishID, slug, dishID, slug,
); err != nil { ); insertErr != nil {
return "", fmt.Errorf("insert dish tag %s: %w", slug, err) return "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr)
} }
} }

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/food-ai/backend/internal/infra/locale"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -26,7 +27,7 @@ func NewHandler(pool *pgxpool.Pool) *Handler {
func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -69,7 +70,7 @@ func (h *Handler) getDailyGoal(ctx context.Context, userID string) int {
userID, userID,
).Scan(&goal) ).Scan(&goal)
if err != nil { if err != nil {
slog.Warn("home: get daily goal", "user_id", userID, "err", err) slog.WarnContext(ctx, "home: get daily goal", "user_id", userID, "err", err)
return 2000 return 2000
} }
return goal return goal
@@ -99,21 +100,24 @@ func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) fl
// getTodayPlan returns the three meal slots planned for today. // getTodayPlan returns the three meal slots planned for today.
// If no menu exists, all three slots are returned with nil recipe fields. // If no menu exists, all three slots are returned with nil recipe fields.
func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan { func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan {
lang := locale.FromContext(ctx)
const q = ` const q = `
SELECT mi.meal_type, SELECT mi.meal_type,
sr.title, COALESCE(dt.name, d.name) AS title,
sr.image_url, d.image_url,
(sr.nutrition->>'calories')::float rec.calories_per_serving
FROM menu_plans mp FROM menu_plans mp
JOIN menu_items mi ON mi.menu_plan_id = mp.id JOIN menu_items mi ON mi.menu_plan_id = mp.id
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id LEFT JOIN recipes rec ON rec.id = mi.recipe_id
LEFT JOIN dishes d ON d.id = rec.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $4
WHERE mp.user_id = $1 WHERE mp.user_id = $1
AND mp.week_start::text = $2 AND mp.week_start::text = $2
AND mi.day_of_week = $3` AND mi.day_of_week = $3`
rows, err := h.pool.Query(ctx, q, userID, weekStart, dow) rows, err := h.pool.Query(ctx, q, userID, weekStart, dow, lang)
if err != nil { if err != nil {
slog.Warn("home: get today plan", "err", err) slog.WarnContext(ctx, "home: get today plan", "err", err)
return defaultPlan() return defaultPlan()
} }
defer rows.Close() defer rows.Close()
@@ -175,7 +179,7 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
userID, userID,
) )
if err != nil { if err != nil {
slog.Warn("home: get expiring soon", "err", err) slog.WarnContext(ctx, "home: get expiring soon", "err", err)
return nil return nil
} }
defer rows.Close() defer rows.Close()
@@ -197,19 +201,25 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
return result return result
} }
// getRecommendations returns the 3 most recently generated recipe recommendations. // getRecommendations returns the 3 most recently saved recommendation recipes.
func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation { func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation {
lang := locale.FromContext(ctx)
rows, err := h.pool.Query(ctx, ` rows, err := h.pool.Query(ctx, `
SELECT id, title, COALESCE(image_url, ''), SELECT rec.id,
(nutrition->>'calories')::float COALESCE(dt.name, d.name),
FROM saved_recipes COALESCE(d.image_url, ''),
WHERE user_id = $1 AND source = 'recommendation' rec.calories_per_serving
ORDER BY saved_at DESC FROM user_saved_recipes usr
JOIN recipes rec ON rec.id = usr.recipe_id
JOIN dishes d ON d.id = rec.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
WHERE usr.user_id = $1 AND rec.source = 'recommendation'
ORDER BY usr.saved_at DESC
LIMIT 3`, LIMIT 3`,
userID, userID, lang,
) )
if err != nil { if err != nil {
slog.Warn("home: get recommendations", "err", err) slog.WarnContext(ctx, "home: get recommendations", "err", err)
return nil return nil
} }
defer rows.Close() defer rows.Close()
@@ -238,10 +248,10 @@ func mondayOfISOWeek(year, week int) time.Time {
return monday1.AddDate(0, 0, (week-1)*7) return monday1.AddDate(0, 0, (week-1)*7)
} }
func writeError(w http.ResponseWriter, status int, msg string) { func writeError(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) _ = json.NewEncoder(w).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(r.Context())})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {

View File

@@ -80,20 +80,20 @@ func NewHandler(
func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN") writeError(w, r, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN")
return return
} }
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil { if err != nil {
slog.Error("get menu", "err", err) slog.ErrorContext(r.Context(), "get menu", "err", err)
writeError(w, http.StatusInternalServerError, "failed to load menu") writeError(w, r, http.StatusInternalServerError, "failed to load menu")
return return
} }
if plan == nil { if plan == nil {
@@ -111,7 +111,7 @@ func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -122,15 +122,15 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
weekStart, err := ResolveWeekStart(body.Week) weekStart, err := ResolveWeekStart(body.Week)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter") writeError(w, r, http.StatusBadRequest, "invalid week parameter")
return return
} }
// Load user profile. // Load user profile.
u, err := h.userLoader.GetByID(r.Context(), userID) u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil { if err != nil {
slog.Error("load user for menu generation", "err", err) slog.ErrorContext(r.Context(), "load user for menu generation", "err", err)
writeError(w, http.StatusInternalServerError, "failed to load user profile") writeError(w, r, http.StatusInternalServerError, "failed to load user profile")
return return
} }
@@ -144,8 +144,8 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
// Generate 7-day plan via Gemini. // Generate 7-day plan via Gemini.
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq) days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
if err != nil { if err != nil {
slog.Error("generate menu", "user_id", userID, "err", err) slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", err)
writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again") writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again")
return return
} }
@@ -166,7 +166,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
defer wg.Done() defer wg.Done()
url, err := h.pexels.SearchPhoto(r.Context(), query) url, err := h.pexels.SearchPhoto(r.Context(), query)
if err != nil { if err != nil {
slog.Warn("pexels search failed", "query", query, "err", err) slog.WarnContext(r.Context(), "pexels search failed", "query", query, "err", err)
} }
mu.Lock() mu.Lock()
imageResults = append(imageResults, indexedRecipe{di, mi, url}) imageResults = append(imageResults, indexedRecipe{di, mi, url})
@@ -191,8 +191,8 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
for mi, meal := range day.Meals { for mi, meal := range day.Meals {
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe)) recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
if err != nil { if err != nil {
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err) slog.ErrorContext(r.Context(), "save recipe for menu", "title", meal.Recipe.Title, "err", err)
writeError(w, http.StatusInternalServerError, "failed to save recipes") writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
return return
} }
refs = append(refs, savedRef{di, mi, recipeID}) refs = append(refs, savedRef{di, mi, recipeID})
@@ -212,23 +212,23 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
// Persist in a single transaction. // Persist in a single transaction.
planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
if err != nil { if err != nil {
slog.Error("save menu plan", "err", err) slog.ErrorContext(r.Context(), "save menu plan", "err", err)
writeError(w, http.StatusInternalServerError, "failed to save menu plan") writeError(w, r, http.StatusInternalServerError, "failed to save menu plan")
return return
} }
// Auto-generate shopping list. // Auto-generate shopping list.
if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil { if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil {
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil { if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil {
slog.Warn("auto-generate shopping list", "err", err) slog.WarnContext(r.Context(), "auto-generate shopping list", "err", err)
} }
} }
// Return the freshly saved plan. // Return the freshly saved plan.
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil || plan == nil { if err != nil || plan == nil {
slog.Error("load generated menu", "err", err, "plan_nil", plan == nil) slog.ErrorContext(r.Context(), "load generated menu", "err", err, "plan_nil", plan == nil)
writeError(w, http.StatusInternalServerError, "failed to load generated menu") writeError(w, r, http.StatusInternalServerError, "failed to load generated menu")
return return
} }
writeJSON(w, http.StatusOK, plan) writeJSON(w, http.StatusOK, plan)
@@ -238,7 +238,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -248,17 +248,17 @@ func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
RecipeID string `json:"recipe_id"` RecipeID string `json:"recipe_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" { if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" {
writeError(w, http.StatusBadRequest, "recipe_id required") writeError(w, r, http.StatusBadRequest, "recipe_id required")
return return
} }
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil { if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
if err == ErrNotFound { if err == ErrNotFound {
writeError(w, http.StatusNotFound, "menu item not found") writeError(w, r, http.StatusNotFound, "menu item not found")
return return
} }
slog.Error("update menu item", "err", err) slog.ErrorContext(r.Context(), "update menu item", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update menu item") writeError(w, r, http.StatusInternalServerError, "failed to update menu item")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -268,18 +268,18 @@ func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
itemID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "id")
if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil { if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil {
if err == ErrNotFound { if err == ErrNotFound {
writeError(w, http.StatusNotFound, "menu item not found") writeError(w, r, http.StatusNotFound, "menu item not found")
return return
} }
slog.Error("delete menu item", "err", err) slog.ErrorContext(r.Context(), "delete menu item", "err", err)
writeError(w, http.StatusInternalServerError, "failed to delete menu item") writeError(w, r, http.StatusInternalServerError, "failed to delete menu item")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -293,7 +293,7 @@ func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) { func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -304,31 +304,31 @@ func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
weekStart, err := ResolveWeekStart(body.Week) weekStart, err := ResolveWeekStart(body.Week)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter") writeError(w, r, http.StatusBadRequest, "invalid week parameter")
return return
} }
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil { if err != nil {
if err == ErrNotFound { if err == ErrNotFound {
writeError(w, http.StatusNotFound, "no menu plan found for this week") writeError(w, r, http.StatusNotFound, "no menu plan found for this week")
return return
} }
slog.Error("get plan id", "err", err) slog.ErrorContext(r.Context(), "get plan id", "err", err)
writeError(w, http.StatusInternalServerError, "failed to find menu plan") writeError(w, r, http.StatusInternalServerError, "failed to find menu plan")
return return
} }
items, err := h.buildShoppingList(r.Context(), planID) items, err := h.buildShoppingList(r.Context(), planID)
if err != nil { if err != nil {
slog.Error("build shopping list", "err", err) slog.ErrorContext(r.Context(), "build shopping list", "err", err)
writeError(w, http.StatusInternalServerError, "failed to build shopping list") writeError(w, r, http.StatusInternalServerError, "failed to build shopping list")
return return
} }
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil { if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
slog.Error("upsert shopping list", "err", err) slog.ErrorContext(r.Context(), "upsert shopping list", "err", err)
writeError(w, http.StatusInternalServerError, "failed to save shopping list") writeError(w, r, http.StatusInternalServerError, "failed to save shopping list")
return return
} }
writeJSON(w, http.StatusOK, items) writeJSON(w, http.StatusOK, items)
@@ -338,13 +338,13 @@ func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter") writeError(w, r, http.StatusBadRequest, "invalid week parameter")
return return
} }
@@ -354,13 +354,13 @@ func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []ShoppingItem{}) writeJSON(w, http.StatusOK, []ShoppingItem{})
return return
} }
writeError(w, http.StatusInternalServerError, "failed to find menu plan") writeError(w, r, http.StatusInternalServerError, "failed to find menu plan")
return return
} }
items, err := h.repo.GetShoppingList(r.Context(), userID, planID) items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shopping list") writeError(w, r, http.StatusInternalServerError, "failed to load shopping list")
return return
} }
if items == nil { if items == nil {
@@ -373,14 +373,14 @@ func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) { func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
indexStr := chi.URLParam(r, "index") indexStr := chi.URLParam(r, "index")
var index int var index int
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 { if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 {
writeError(w, http.StatusBadRequest, "invalid item index") writeError(w, r, http.StatusBadRequest, "invalid item index")
return return
} }
@@ -388,25 +388,25 @@ func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
Checked bool `json:"checked"` Checked bool `json:"checked"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body") writeError(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter") writeError(w, r, http.StatusBadRequest, "invalid week parameter")
return return
} }
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "menu plan not found") writeError(w, r, http.StatusNotFound, "menu plan not found")
return return
} }
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil { if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
slog.Error("toggle shopping item", "err", err) slog.ErrorContext(r.Context(), "toggle shopping item", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update item") writeError(w, r, http.StatusInternalServerError, "failed to update item")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -568,13 +568,17 @@ func mondayOfISOWeek(year, week int) time.Time {
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeError(w http.ResponseWriter, status int, msg string) { func writeError(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(w).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {

View File

@@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/food-ai/backend/internal/infra/middleware"
) )
// ProductSearcher is the data layer interface used by Handler for search. // ProductSearcher is the data layer interface used by Handler for search.
@@ -51,7 +52,7 @@ func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http
products, searchError := handler.repo.Search(request.Context(), query, limit) products, searchError := handler.repo.Search(request.Context(), query, limit)
if searchError != nil { if searchError != nil {
slog.Error("search catalog products", "q", query, "err", searchError) slog.ErrorContext(request.Context(), "search catalog products", "q", query, "err", searchError)
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(http.StatusInternalServerError) responseWriter.WriteHeader(http.StatusInternalServerError)
_, _ = responseWriter.Write([]byte(`{"error":"search failed"}`)) _, _ = responseWriter.Write([]byte(`{"error":"search failed"}`))
@@ -71,15 +72,15 @@ func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http
func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) { func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) {
barcode := chi.URLParam(request, "barcode") barcode := chi.URLParam(request, "barcode")
if barcode == "" { if barcode == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "barcode is required") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "barcode is required")
return return
} }
// Check the local catalog first. // Check the local catalog first.
catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode) catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode)
if lookupError != nil { if lookupError != nil {
slog.Error("lookup product by barcode", "barcode", barcode, "err", lookupError) slog.ErrorContext(request.Context(), "lookup product by barcode", "barcode", barcode, "err", lookupError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "lookup failed") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "lookup failed")
return return
} }
if catalogProduct != nil { if catalogProduct != nil {
@@ -90,15 +91,15 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request
// Not in catalog — fetch from Open Food Facts. // Not in catalog — fetch from Open Food Facts.
fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode) fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode)
if fetchError != nil { if fetchError != nil {
slog.Warn("open food facts fetch failed", "barcode", barcode, "err", fetchError) slog.WarnContext(request.Context(), "open food facts fetch failed", "barcode", barcode, "err", fetchError)
writeErrorJSON(responseWriter, http.StatusNotFound, "product not found") writeErrorJSON(responseWriter, request, http.StatusNotFound, "product not found")
return return
} }
// Persist the fetched product so subsequent lookups are served from the DB. // Persist the fetched product so subsequent lookups are served from the DB.
savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct) savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct)
if upsertError != nil { if upsertError != nil {
slog.Warn("upsert product from open food facts", "barcode", barcode, "err", upsertError) slog.WarnContext(request.Context(), "upsert product from open food facts", "barcode", barcode, "err", upsertError)
// Return the fetched data even if we could not cache it. // Return the fetched data even if we could not cache it.
writeJSON(responseWriter, http.StatusOK, fetchedProduct) writeJSON(responseWriter, http.StatusOK, fetchedProduct)
return return
@@ -107,13 +108,17 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(responseWriter http.ResponseWriter, status int, msg string) { func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(responseWriter).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(request.Context()),
})
} }
func writeJSON(responseWriter http.ResponseWriter, status int, value any) { func writeJSON(responseWriter http.ResponseWriter, status int, value any) {

View File

@@ -23,32 +23,36 @@ func NewHandler(repo *Repository) *Handler {
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
rec, err := h.repo.GetByID(r.Context(), id) rec, err := h.repo.GetByID(r.Context(), id)
if err != nil { if err != nil {
slog.Error("get recipe", "id", id, "err", err) slog.ErrorContext(r.Context(), "get recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe") writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get recipe")
return return
} }
if rec == nil { if rec == nil {
writeErrorJSON(w, http.StatusNotFound, "recipe not found") writeErrorJSON(w, r, http.StatusNotFound, "recipe not found")
return return
} }
writeJSON(w, http.StatusOK, rec) writeJSON(w, http.StatusOK, rec)
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(w http.ResponseWriter, status int, msg string) { func writeErrorJSON(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(w).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {

View File

@@ -125,15 +125,15 @@ func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, req
var req imageRequest var req imageRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" { if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "image_base64 is required")
return return
} }
lang := locale.FromContext(request.Context()) lang := locale.FromContext(request.Context())
result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang) result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang)
if recognizeError != nil { if recognizeError != nil {
slog.Error("recognize receipt", "err", recognizeError) slog.ErrorContext(request.Context(), "recognize receipt", "err", recognizeError)
writeErrorJSON(responseWriter, http.StatusServiceUnavailable, "recognition failed, please try again") writeErrorJSON(responseWriter, request, http.StatusServiceUnavailable, "recognition failed, please try again")
return return
} }
@@ -149,7 +149,7 @@ func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, req
func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) { func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) {
var req imagesRequest var req imagesRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || len(req.Images) == 0 { if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || len(req.Images) == 0 {
writeErrorJSON(responseWriter, http.StatusBadRequest, "at least one image is required") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "at least one image is required")
return return
} }
if len(req.Images) > 3 { if len(req.Images) > 3 {
@@ -165,7 +165,7 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re
defer wg.Done() defer wg.Done()
items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang) items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang)
if recognizeError != nil { if recognizeError != nil {
slog.Warn("recognize products from image", "index", index, "err", recognizeError) slog.WarnContext(request.Context(), "recognize products from image", "index", index, "err", recognizeError)
return return
} }
allItems[index] = items allItems[index] = items
@@ -184,7 +184,7 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re
func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, request *http.Request) { func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, request *http.Request) {
var req recognizeDishRequest var req recognizeDishRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" { if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "image_base64 is required")
return return
} }
@@ -202,8 +202,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
TargetMealType: req.TargetMealType, TargetMealType: req.TargetMealType,
} }
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil { if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
slog.Error("insert recognition job", "err", insertError) slog.ErrorContext(request.Context(), "insert recognition job", "err", insertError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create job") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create job")
return return
} }
@@ -217,8 +217,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
topic = TopicPaid topic = TopicPaid
} }
if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil { if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil {
slog.Error("publish recognition job", "job_id", job.ID, "err", publishError) slog.ErrorContext(request.Context(), "publish recognition job", "job_id", job.ID, "err", publishError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to enqueue job") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to enqueue job")
return return
} }
@@ -236,8 +236,8 @@ func (handler *Handler) ListTodayJobs(responseWriter http.ResponseWriter, reques
summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID) summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID)
if listError != nil { if listError != nil {
slog.Error("list today unlinked jobs", "err", listError) slog.ErrorContext(request.Context(), "list today unlinked jobs", "err", listError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs")
return return
} }
@@ -254,8 +254,8 @@ func (handler *Handler) ListAllJobs(responseWriter http.ResponseWriter, request
summaries, listError := handler.jobRepo.ListAll(request.Context(), userID) summaries, listError := handler.jobRepo.ListAll(request.Context(), userID)
if listError != nil { if listError != nil {
slog.Error("list all jobs", "err", listError) slog.ErrorContext(request.Context(), "list all jobs", "err", listError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs")
return return
} }
@@ -277,11 +277,11 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http
job, fetchError := handler.jobRepo.GetJobByID(request.Context(), jobID) job, fetchError := handler.jobRepo.GetJobByID(request.Context(), jobID)
if fetchError != nil { if fetchError != nil {
writeErrorJSON(responseWriter, http.StatusNotFound, "job not found") writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found")
return return
} }
if job.UserID != userID { if job.UserID != userID {
writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden") writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden")
return return
} }
writeJSON(responseWriter, http.StatusOK, job) writeJSON(responseWriter, http.StatusOK, job)
@@ -307,7 +307,7 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt
catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name) catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name)
if matchError != nil { if matchError != nil {
slog.Warn("fuzzy match product", "name", item.Name, "err", matchError) slog.WarnContext(ctx, "fuzzy match product", "name", item.Name, "err", matchError)
} }
if catalogProduct != nil { if catalogProduct != nil {
@@ -325,7 +325,7 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt
} else { } else {
classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name) classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name)
if classifyError != nil { if classifyError != nil {
slog.Warn("classify unknown product", "name", item.Name, "err", classifyError) slog.WarnContext(ctx, "classify unknown product", "name", item.Name, "err", classifyError)
} else { } else {
saved := handler.saveClassification(ctx, classification) saved := handler.saveClassification(ctx, classification)
if saved != nil { if saved != nil {
@@ -361,7 +361,7 @@ func (handler *Handler) saveClassification(ctx context.Context, classification *
saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct) saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct)
if upsertError != nil { if upsertError != nil {
slog.Warn("upsert classified product", "name", classification.CanonicalName, "err", upsertError) slog.WarnContext(ctx, "upsert classified product", "name", classification.CanonicalName, "err", upsertError)
return nil return nil
} }
@@ -417,13 +417,17 @@ func intPtr(n int) *int {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(responseWriter http.ResponseWriter, status int, msg string) { func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(responseWriter).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(request.Context()),
})
} }
func writeJSON(responseWriter http.ResponseWriter, status int, value any) { func writeJSON(responseWriter http.ResponseWriter, status int, value any) {

View File

@@ -147,17 +147,17 @@ func (broker *SSEBroker) ServeSSE(responseWriter http.ResponseWriter, request *h
job, fetchError := broker.jobRepo.GetJobByID(request.Context(), jobID) job, fetchError := broker.jobRepo.GetJobByID(request.Context(), jobID)
if fetchError != nil { if fetchError != nil {
writeErrorJSON(responseWriter, http.StatusNotFound, "job not found") writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found")
return return
} }
if job.UserID != userID { if job.UserID != userID {
writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden") writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden")
return return
} }
flusher, supported := responseWriter.(http.Flusher) flusher, supported := responseWriter.(http.Flusher)
if !supported { if !supported {
writeErrorJSON(responseWriter, http.StatusInternalServerError, "streaming not supported") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "streaming not supported")
return return
} }

View File

@@ -62,7 +62,7 @@ func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoade
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -75,8 +75,8 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
u, err := h.userLoader.GetByID(r.Context(), userID) u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil { if err != nil {
slog.Error("load user for recommendations", "user_id", userID, "err", err) slog.ErrorContext(r.Context(), "load user for recommendations", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile") writeErrorJSON(w, r, http.StatusInternalServerError, "failed to load user profile")
return return
} }
@@ -86,13 +86,13 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
req.AvailableProducts = products req.AvailableProducts = products
} else { } else {
slog.Warn("load products for recommendations", "user_id", userID, "err", err) slog.WarnContext(r.Context(), "load products for recommendations", "user_id", userID, "err", err)
} }
recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req) recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req)
if err != nil { if err != nil {
slog.Error("generate recipes", "user_id", userID, "err", err) slog.ErrorContext(r.Context(), "generate recipes", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again") writeErrorJSON(w, r, http.StatusServiceUnavailable, "recipe generation failed, please try again")
return return
} }
@@ -104,7 +104,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
defer wg.Done() defer wg.Done()
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery) imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
if err != nil { if err != nil {
slog.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err) slog.WarnContext(r.Context(), "pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
} }
recipes[i].ImageURL = imageURL recipes[i].ImageURL = imageURL
}(i) }(i)
@@ -139,14 +139,18 @@ func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest {
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(w http.ResponseWriter, status int, msg string) { func writeErrorJSON(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { if encodeErr := json.NewEncoder(w).Encode(errorResponse{
slog.Error("write error response", "err", err) Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
}); encodeErr != nil {
slog.ErrorContext(r.Context(), "write error response", "err", encodeErr)
} }
} }

View File

@@ -35,25 +35,25 @@ func NewHandler(repo SavedRecipeRepository) *Handler {
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req SaveRequest var req SaveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body") writeErrorJSON(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
if req.Title == "" && req.RecipeID == "" { if req.Title == "" && req.RecipeID == "" {
writeErrorJSON(w, http.StatusBadRequest, "title or recipe_id is required") writeErrorJSON(w, r, http.StatusBadRequest, "title or recipe_id is required")
return return
} }
rec, err := h.repo.Save(r.Context(), userID, req) rec, err := h.repo.Save(r.Context(), userID, req)
if err != nil { if err != nil {
slog.Error("save recipe", "err", err) slog.ErrorContext(r.Context(), "save recipe", "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe") writeErrorJSON(w, r, http.StatusInternalServerError, "failed to save recipe")
return return
} }
writeJSON(w, http.StatusCreated, rec) writeJSON(w, http.StatusCreated, rec)
@@ -63,14 +63,14 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
recipes, err := h.repo.List(r.Context(), userID) recipes, err := h.repo.List(r.Context(), userID)
if err != nil { if err != nil {
slog.Error("list saved recipes", "err", err) slog.ErrorContext(r.Context(), "list saved recipes", "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes") writeErrorJSON(w, r, http.StatusInternalServerError, "failed to list saved recipes")
return return
} }
if recipes == nil { if recipes == nil {
@@ -83,19 +83,19 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
rec, err := h.repo.GetByID(r.Context(), userID, id) rec, err := h.repo.GetByID(r.Context(), userID, id)
if err != nil { if err != nil {
slog.Error("get saved recipe", "id", id, "err", err) slog.ErrorContext(r.Context(), "get saved recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe") writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get saved recipe")
return return
} }
if rec == nil { if rec == nil {
writeErrorJSON(w, http.StatusNotFound, "recipe not found") writeErrorJSON(w, r, http.StatusNotFound, "recipe not found")
return return
} }
writeJSON(w, http.StatusOK, rec) writeJSON(w, http.StatusOK, rec)
@@ -105,32 +105,36 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), userID, id); err != nil { if err := h.repo.Delete(r.Context(), userID, id); err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
writeErrorJSON(w, http.StatusNotFound, "recipe not found") writeErrorJSON(w, r, http.StatusNotFound, "recipe not found")
return return
} }
slog.Error("delete saved recipe", "id", id, "err", err) slog.ErrorContext(r.Context(), "delete saved recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe") writeErrorJSON(w, r, http.StatusInternalServerError, "failed to delete recipe")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(w http.ResponseWriter, status int, msg string) { func writeErrorJSON(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { if encodeErr := json.NewEncoder(w).Encode(errorResponse{
slog.Error("write error response", "err", err) Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
}); encodeErr != nil {
slog.ErrorContext(r.Context(), "write error response", "err", encodeErr)
} }
} }

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -26,8 +27,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug AND tt.lang = $1 LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug AND tt.lang = $1
ORDER BY t.sort_order`, lang) ORDER BY t.sort_order`, lang)
if queryError != nil { if queryError != nil {
slog.Error("list tags", "err", queryError) slog.ErrorContext(request.Context(), "list tags", "err", queryError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
return return
} }
defer rows.Close() defer rows.Close()
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var item tagItem var item tagItem
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil { if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
slog.Error("scan tag row", "err", scanError) slog.ErrorContext(request.Context(), "scan tag row", "err", scanError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
return return
} }
items = append(items, item) items = append(items, item)
} }
if rowsError := rows.Err(); rowsError != nil { if rowsError := rows.Err(); rowsError != nil {
slog.Error("iterate tag rows", "err", rowsError) slog.ErrorContext(request.Context(), "iterate tag rows", "err", rowsError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
return return
} }
@@ -53,8 +54,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
} }
} }
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message string) { func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(map[string]string{"error": message}) _ = json.NewEncoder(responseWriter).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(request.Context())})
} }

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -26,8 +27,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
LEFT JOIN unit_translations ut ON ut.unit_code = u.code AND ut.lang = $1 LEFT JOIN unit_translations ut ON ut.unit_code = u.code AND ut.lang = $1
ORDER BY u.sort_order`, lang) ORDER BY u.sort_order`, lang)
if queryError != nil { if queryError != nil {
slog.Error("list units", "err", queryError) slog.ErrorContext(request.Context(), "list units", "err", queryError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
return return
} }
defer rows.Close() defer rows.Close()
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var item unitItem var item unitItem
if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil { if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil {
slog.Error("scan unit row", "err", scanError) slog.ErrorContext(request.Context(), "scan unit row", "err", scanError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
return return
} }
items = append(items, item) items = append(items, item)
} }
if rowsError := rows.Err(); rowsError != nil { if rowsError := rows.Err(); rowsError != nil {
slog.Error("iterate unit rows", "err", rowsError) slog.ErrorContext(request.Context(), "iterate unit rows", "err", rowsError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
return return
} }
@@ -53,8 +54,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
} }
} }
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message string) { func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(map[string]string{"error": message}) _ = json.NewEncoder(responseWriter).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(request.Context())})
} }

View File

@@ -21,13 +21,13 @@ func NewHandler(service *Service) *Handler {
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
u, err := h.service.GetProfile(r.Context(), userID) u, err := h.service.GetProfile(r.Context(), userID)
if err != nil { if err != nil {
writeErrorJSON(w, http.StatusNotFound, "user not found") writeErrorJSON(w, r, http.StatusNotFound, "user not found")
return return
} }
@@ -37,20 +37,20 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) userID := middleware.UserIDFromCtx(r.Context())
if userID == "" { if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req UpdateProfileRequest var req UpdateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body") writeErrorJSON(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
u, err := h.service.UpdateProfile(r.Context(), userID, req) u, err := h.service.UpdateProfile(r.Context(), userID, req)
if err != nil { if err != nil {
writeErrorJSON(w, http.StatusBadRequest, err.Error()) writeErrorJSON(w, r, http.StatusBadRequest, err.Error())
return return
} }
@@ -58,14 +58,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(w http.ResponseWriter, status int, msg string) { func writeErrorJSON(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { if encodeErr := json.NewEncoder(w).Encode(errorResponse{
slog.Error("failed to write error response", "err", err) Error: msg,
RequestID: middleware.RequestIDFromCtx(r.Context()),
}); encodeErr != nil {
slog.ErrorContext(r.Context(), "failed to write error response", "err", encodeErr)
} }
} }

View File

@@ -35,8 +35,8 @@ func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.R
userID := middleware.UserIDFromCtx(request.Context()) userID := middleware.UserIDFromCtx(request.Context())
userProducts, listError := handler.repo.List(request.Context(), userID) userProducts, listError := handler.repo.List(request.Context(), userID)
if listError != nil { if listError != nil {
slog.Error("list user products", "user_id", userID, "err", listError) slog.ErrorContext(request.Context(), "list user products", "user_id", userID, "err", listError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list user products") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list user products")
return return
} }
if userProducts == nil { if userProducts == nil {
@@ -50,18 +50,18 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http
userID := middleware.UserIDFromCtx(request.Context()) userID := middleware.UserIDFromCtx(request.Context())
var req CreateRequest var req CreateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil { if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
return return
} }
if req.Name == "" { if req.Name == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "name is required") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "name is required")
return return
} }
userProduct, createError := handler.repo.Create(request.Context(), userID, req) userProduct, createError := handler.repo.Create(request.Context(), userID, req)
if createError != nil { if createError != nil {
slog.Error("create user product", "user_id", userID, "err", createError) slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user product") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product")
return return
} }
writeJSON(responseWriter, http.StatusCreated, userProduct) writeJSON(responseWriter, http.StatusCreated, userProduct)
@@ -72,7 +72,7 @@ func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request
userID := middleware.UserIDFromCtx(request.Context()) userID := middleware.UserIDFromCtx(request.Context())
var items []CreateRequest var items []CreateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&items); decodeError != nil { if decodeError := json.NewDecoder(request.Body).Decode(&items); decodeError != nil {
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
return return
} }
if len(items) == 0 { if len(items) == 0 {
@@ -82,8 +82,8 @@ func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request
userProducts, batchError := handler.repo.BatchCreate(request.Context(), userID, items) userProducts, batchError := handler.repo.BatchCreate(request.Context(), userID, items)
if batchError != nil { if batchError != nil {
slog.Error("batch create user products", "user_id", userID, "err", batchError) slog.ErrorContext(request.Context(), "batch create user products", "user_id", userID, "err", batchError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user products") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user products")
return return
} }
writeJSON(responseWriter, http.StatusCreated, userProducts) writeJSON(responseWriter, http.StatusCreated, userProducts)
@@ -96,18 +96,18 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http
var req UpdateRequest var req UpdateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil { if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body") writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
return return
} }
userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req) userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req)
if errors.Is(updateError, ErrNotFound) { if errors.Is(updateError, ErrNotFound) {
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found") writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found")
return return
} }
if updateError != nil { if updateError != nil {
slog.Error("update user product", "id", id, "err", updateError) slog.ErrorContext(request.Context(), "update user product", "id", id, "err", updateError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to update user product") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to update user product")
return return
} }
writeJSON(responseWriter, http.StatusOK, userProduct) writeJSON(responseWriter, http.StatusOK, userProduct)
@@ -120,24 +120,28 @@ func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http
if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil { if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil {
if errors.Is(deleteError, ErrNotFound) { if errors.Is(deleteError, ErrNotFound) {
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found") writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found")
return return
} }
slog.Error("delete user product", "id", id, "err", deleteError) slog.ErrorContext(request.Context(), "delete user product", "id", id, "err", deleteError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to delete user product") writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete user product")
return return
} }
responseWriter.WriteHeader(http.StatusNoContent) responseWriter.WriteHeader(http.StatusNoContent)
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
} }
func writeErrorJSON(responseWriter http.ResponseWriter, status int, msg string) { func writeErrorJSON(responseWriter http.ResponseWriter, request *http.Request, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(responseWriter).Encode(errorResponse{
Error: msg,
RequestID: middleware.RequestIDFromCtx(request.Context()),
})
} }
func writeJSON(responseWriter http.ResponseWriter, status int, value any) { func writeJSON(responseWriter http.ResponseWriter, status int, value any) {

View File

@@ -0,0 +1,42 @@
package reqlog
import (
"context"
"log/slog"
"runtime/debug"
"github.com/food-ai/backend/internal/infra/middleware"
)
// Handler is a slog.Handler wrapper that enriches ERROR-level records with
// the request_id from context and a goroutine stack trace.
type Handler struct {
inner slog.Handler
}
// New wraps inner with request-aware enrichment.
func New(inner slog.Handler) *Handler {
return &Handler{inner: inner}
}
func (handler *Handler) Handle(ctx context.Context, record slog.Record) error {
if record.Level >= slog.LevelError {
if requestID := middleware.RequestIDFromCtx(ctx); requestID != "" {
record.AddAttrs(slog.String("request_id", requestID))
}
record.AddAttrs(slog.String("stack", string(debug.Stack())))
}
return handler.inner.Handle(ctx, record)
}
func (handler *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return handler.inner.Enabled(ctx, level)
}
func (handler *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &Handler{inner: handler.inner.WithAttrs(attrs)}
}
func (handler *Handler) WithGroup(name string) slog.Handler {
return &Handler{inner: handler.inner.WithGroup(name)}
}

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TYPE recipe_source ADD VALUE IF NOT EXISTS 'menu';
-- +goose Down
-- Cannot remove enum values in PostgreSQL

View File

@@ -0,0 +1,27 @@
-- +goose Up
ALTER TABLE recipe_products RENAME TO recipe_ingredients;
ALTER INDEX idx_recipe_products_recipe_id RENAME TO idx_recipe_ingredients_recipe_id;
ALTER TABLE recipe_ingredients
DROP CONSTRAINT recipe_products_product_id_fkey;
ALTER TABLE recipe_ingredients
RENAME COLUMN product_id TO ingredient_id;
ALTER TABLE recipe_ingredients
ADD CONSTRAINT recipe_ingredients_ingredient_id_fkey
FOREIGN KEY (ingredient_id) REFERENCES products(id) ON DELETE SET NULL;
ALTER TABLE recipe_product_translations RENAME TO recipe_ingredient_translations;
-- +goose Down
ALTER TABLE recipe_ingredient_translations RENAME TO recipe_product_translations;
ALTER TABLE recipe_ingredients
DROP CONSTRAINT recipe_ingredients_ingredient_id_fkey;
ALTER TABLE recipe_ingredients
RENAME COLUMN ingredient_id TO product_id;
ALTER TABLE recipe_ingredients
ADD CONSTRAINT recipe_products_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL;
ALTER INDEX idx_recipe_ingredients_recipe_id RENAME TO idx_recipe_products_recipe_id;
ALTER TABLE recipe_ingredients RENAME TO recipe_products;

View File

@@ -96,7 +96,7 @@ class HomeScreen extends ConsumerWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isFutureDate) if (isFutureDate)
_PlanningBanner(dateString: dateString) _FutureDayHeader(dateString: dateString)
else ...[ else ...[
_CaloriesCard( _CaloriesCard(
loggedCalories: loggedCalories, loggedCalories: loggedCalories,
@@ -1143,6 +1143,163 @@ class _DiaryEntryTile extends StatelessWidget {
} }
} }
// ── Future day header (wraps banner + menu-gen CTA) ────────────
class _FutureDayHeader extends ConsumerWidget {
final String dateString;
const _FutureDayHeader({required this.dateString});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final date = DateTime.parse(dateString);
final weekString = isoWeekString(date);
final menuState = ref.watch(menuProvider(weekString));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PlanningBanner(dateString: dateString),
const SizedBox(height: 8),
menuState.when(
loading: () => _GenerateLoadingCard(l10n: l10n),
error: (_, __) => _GenerateActionCard(
l10n: l10n,
onGenerate: () =>
ref.read(menuProvider(weekString).notifier).generate(),
),
data: (plan) => plan == null
? _GenerateActionCard(
l10n: l10n,
onGenerate: () =>
ref.read(menuProvider(weekString).notifier).generate(),
)
: _WeekPlannedChip(l10n: l10n),
),
],
);
}
}
class _GenerateActionCard extends StatelessWidget {
final AppLocalizations l10n;
final VoidCallback onGenerate;
const _GenerateActionCard({required this.l10n, required this.onGenerate});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome,
color: theme.colorScheme.primary, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.generateWeekLabel,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 4),
Text(
l10n.generateWeekSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: onGenerate,
child: Text(l10n.generateWeekLabel),
),
],
),
);
}
}
class _GenerateLoadingCard extends StatelessWidget {
final AppLocalizations l10n;
const _GenerateLoadingCard({required this.l10n});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(theme.colorScheme.primary),
),
),
const SizedBox(width: 12),
Text(
l10n.generatingMenu,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _WeekPlannedChip extends StatelessWidget {
final AppLocalizations l10n;
const _WeekPlannedChip({required this.l10n});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
color: theme.colorScheme.onSecondaryContainer, size: 18),
const SizedBox(width: 8),
Text(
l10n.weekPlannedLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ── Planning banner (future dates) ──────────────────────────── // ── Planning banner (future dates) ────────────────────────────
class _PlanningBanner extends StatelessWidget { class _PlanningBanner extends StatelessWidget {

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "وضع علامة كمأكول",
"plannedMealLabel": "" "plannedMealLabel": "مخطط",
"generateWeekLabel": "تخطيط الأسبوع",
"generateWeekSubtitle": "سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع",
"generatingMenu": "جارٍ إنشاء القائمة...",
"weekPlannedLabel": "تم تخطيط الأسبوع"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "Als gegessen markieren",
"plannedMealLabel": "" "plannedMealLabel": "Geplant",
"generateWeekLabel": "Woche planen",
"generateWeekSubtitle": "KI erstellt einen Menüplan mit Frühstück, Mittagessen und Abendessen für die ganze Woche",
"generatingMenu": "Menü wird erstellt...",
"weekPlannedLabel": "Woche geplant"
} }

View File

@@ -129,5 +129,9 @@
} }
}, },
"markAsEaten": "Mark as eaten", "markAsEaten": "Mark as eaten",
"plannedMealLabel": "Planned" "plannedMealLabel": "Planned",
"generateWeekLabel": "Plan the week",
"generateWeekSubtitle": "AI will create a menu with breakfast, lunch and dinner for the whole week",
"generatingMenu": "Generating menu...",
"weekPlannedLabel": "Week planned"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "Marcar como comido",
"plannedMealLabel": "" "plannedMealLabel": "Planificado",
"generateWeekLabel": "Planificar la semana",
"generateWeekSubtitle": "La IA creará un menú con desayuno, comida y cena para toda la semana",
"generatingMenu": "Generando menú...",
"weekPlannedLabel": "Semana planificada"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "Marquer comme mangé",
"plannedMealLabel": "" "plannedMealLabel": "Planifié",
"generateWeekLabel": "Planifier la semaine",
"generateWeekSubtitle": "L'IA créera un menu avec petit-déjeuner, déjeuner et dîner pour toute la semaine",
"generatingMenu": "Génération du menu...",
"weekPlannedLabel": "Semaine planifiée"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "खाया हुआ चिह्नित करें",
"plannedMealLabel": "" "plannedMealLabel": "नियोजित",
"generateWeekLabel": "सप्ताह की योजना बनाएं",
"generateWeekSubtitle": "AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा",
"generatingMenu": "मेनू बना रहे हैं...",
"weekPlannedLabel": "सप्ताह की योजना बनाई गई"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "Segna come mangiato",
"plannedMealLabel": "" "plannedMealLabel": "Pianificato",
"generateWeekLabel": "Pianifica la settimana",
"generateWeekSubtitle": "L'AI creerà un menu con colazione, pranzo e cena per tutta la settimana",
"generatingMenu": "Generazione menu...",
"weekPlannedLabel": "Settimana pianificata"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "食べた印をつける",
"plannedMealLabel": "" "plannedMealLabel": "予定済み",
"generateWeekLabel": "週を計画する",
"generateWeekSubtitle": "AIが一週間の朝食・昼食・夕食のメニューを作成します",
"generatingMenu": "メニューを生成中...",
"weekPlannedLabel": "週の計画済み"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "먹은 것으로 표시",
"plannedMealLabel": "" "plannedMealLabel": "계획됨",
"generateWeekLabel": "주간 계획하기",
"generateWeekSubtitle": "AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다",
"generatingMenu": "메뉴 생성 중...",
"weekPlannedLabel": "주간 계획 완료"
} }

View File

@@ -807,6 +807,30 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Planned'** /// **'Planned'**
String get plannedMealLabel; String get plannedMealLabel;
/// No description provided for @generateWeekLabel.
///
/// In en, this message translates to:
/// **'Plan the week'**
String get generateWeekLabel;
/// No description provided for @generateWeekSubtitle.
///
/// In en, this message translates to:
/// **'AI will create a menu with breakfast, lunch and dinner for the whole week'**
String get generateWeekSubtitle;
/// No description provided for @generatingMenu.
///
/// In en, this message translates to:
/// **'Generating menu...'**
String get generatingMenu;
/// No description provided for @weekPlannedLabel.
///
/// In en, this message translates to:
/// **'Week planned'**
String get weekPlannedLabel;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -355,8 +355,21 @@ class AppLocalizationsAr extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'وضع علامة كمأكول';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'مخطط';
@override
String get generateWeekLabel => 'تخطيط الأسبوع';
@override
String get generateWeekSubtitle =>
'سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع';
@override
String get generatingMenu => 'جارٍ إنشاء القائمة...';
@override
String get weekPlannedLabel => 'تم تخطيط الأسبوع';
} }

View File

@@ -357,8 +357,21 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'Als gegessen markieren';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'Geplant';
@override
String get generateWeekLabel => 'Woche planen';
@override
String get generateWeekSubtitle =>
'KI erstellt einen Menüplan mit Frühstück, Mittagessen und Abendessen für die ganze Woche';
@override
String get generatingMenu => 'Menü wird erstellt...';
@override
String get weekPlannedLabel => 'Woche geplant';
} }

View File

@@ -359,4 +359,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get plannedMealLabel => 'Planned'; String get plannedMealLabel => 'Planned';
@override
String get generateWeekLabel => 'Plan the week';
@override
String get generateWeekSubtitle =>
'AI will create a menu with breakfast, lunch and dinner for the whole week';
@override
String get generatingMenu => 'Generating menu...';
@override
String get weekPlannedLabel => 'Week planned';
} }

View File

@@ -357,8 +357,21 @@ class AppLocalizationsEs extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'Marcar como comido';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'Planificado';
@override
String get generateWeekLabel => 'Planificar la semana';
@override
String get generateWeekSubtitle =>
'La IA creará un menú con desayuno, comida y cena para toda la semana';
@override
String get generatingMenu => 'Generando menú...';
@override
String get weekPlannedLabel => 'Semana planificada';
} }

View File

@@ -358,8 +358,21 @@ class AppLocalizationsFr extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'Marquer comme mangé';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'Planifié';
@override
String get generateWeekLabel => 'Planifier la semaine';
@override
String get generateWeekSubtitle =>
'L\'IA créera un menu avec petit-déjeuner, déjeuner et dîner pour toute la semaine';
@override
String get generatingMenu => 'Génération du menu...';
@override
String get weekPlannedLabel => 'Semaine planifiée';
} }

View File

@@ -356,8 +356,21 @@ class AppLocalizationsHi extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'खाया हुआ चिह्नित करें';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'नियोजित';
@override
String get generateWeekLabel => 'सप्ताह की योजना बनाएं';
@override
String get generateWeekSubtitle =>
'AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा';
@override
String get generatingMenu => 'मेनू बना रहे हैं...';
@override
String get weekPlannedLabel => 'सप्ताह की योजना बनाई गई';
} }

View File

@@ -357,8 +357,21 @@ class AppLocalizationsIt extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'Segna come mangiato';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'Pianificato';
@override
String get generateWeekLabel => 'Pianifica la settimana';
@override
String get generateWeekSubtitle =>
'L\'AI creerà un menu con colazione, pranzo e cena per tutta la settimana';
@override
String get generatingMenu => 'Generazione menu...';
@override
String get weekPlannedLabel => 'Settimana pianificata';
} }

View File

@@ -354,8 +354,20 @@ class AppLocalizationsJa extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => '食べた印をつける';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => '予定済み';
@override
String get generateWeekLabel => '週を計画する';
@override
String get generateWeekSubtitle => 'AIが一週間の朝食・昼食・夕食のメニューを作成します';
@override
String get generatingMenu => 'メニューを生成中...';
@override
String get weekPlannedLabel => '週の計画済み';
} }

View File

@@ -354,8 +354,20 @@ class AppLocalizationsKo extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => '먹은 것으로 표시';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => '계획됨';
@override
String get generateWeekLabel => '주간 계획하기';
@override
String get generateWeekSubtitle => 'AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다';
@override
String get generatingMenu => '메뉴 생성 중...';
@override
String get weekPlannedLabel => '주간 계획 완료';
} }

View File

@@ -357,8 +357,21 @@ class AppLocalizationsPt extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => 'Marcar como comido';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => 'Planejado';
@override
String get generateWeekLabel => 'Planejar a semana';
@override
String get generateWeekSubtitle =>
'A IA criará um menu com café da manhã, almoço e jantar para a semana inteira';
@override
String get generatingMenu => 'Gerando menu...';
@override
String get weekPlannedLabel => 'Semana planejada';
} }

View File

@@ -359,4 +359,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get plannedMealLabel => 'Запланировано'; String get plannedMealLabel => 'Запланировано';
@override
String get generateWeekLabel => 'Запланировать неделю';
@override
String get generateWeekSubtitle =>
'AI составит меню с завтраком, обедом и ужином на всю неделю';
@override
String get generatingMenu => 'Генерируем меню...';
@override
String get weekPlannedLabel => 'Неделя запланирована';
} }

View File

@@ -354,8 +354,20 @@ class AppLocalizationsZh extends AppLocalizations {
} }
@override @override
String get markAsEaten => ''; String get markAsEaten => '标记为已吃';
@override @override
String get plannedMealLabel => ''; String get plannedMealLabel => '已计划';
@override
String get generateWeekLabel => '规划本周';
@override
String get generateWeekSubtitle => 'AI将为整周创建含早餐、午餐和晚餐的菜单';
@override
String get generatingMenu => '正在生成菜单...';
@override
String get weekPlannedLabel => '本周已规划';
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "Marcar como comido",
"plannedMealLabel": "" "plannedMealLabel": "Planejado",
"generateWeekLabel": "Planejar a semana",
"generateWeekSubtitle": "A IA criará um menu com café da manhã, almoço e jantar para a semana inteira",
"generatingMenu": "Gerando menu...",
"weekPlannedLabel": "Semana planejada"
} }

View File

@@ -129,5 +129,9 @@
} }
}, },
"markAsEaten": "Отметить как съеденное", "markAsEaten": "Отметить как съеденное",
"plannedMealLabel": "Запланировано" "plannedMealLabel": "Запланировано",
"generateWeekLabel": "Запланировать неделю",
"generateWeekSubtitle": "AI составит меню с завтраком, обедом и ужином на всю неделю",
"generatingMenu": "Генерируем меню...",
"weekPlannedLabel": "Неделя запланирована"
} }

View File

@@ -130,6 +130,10 @@
"date": { "type": "String" } "date": { "type": "String" }
} }
}, },
"markAsEaten": "", "markAsEaten": "标记为已吃",
"plannedMealLabel": "" "plannedMealLabel": "已计划",
"generateWeekLabel": "规划本周",
"generateWeekSubtitle": "AI将为整周创建含早餐、午餐和晚餐的菜单",
"generatingMenu": "正在生成菜单...",
"weekPlannedLabel": "本周已规划"
} }

View File

@@ -125,10 +125,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -689,26 +689,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1078,10 +1078,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.10"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description: