From 5096df21020cd78d7b57fd1ba84d4c79720355a4 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 22 Mar 2026 00:35:11 +0200 Subject: [PATCH] fix: fix menu generation errors and show planned meals on home screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + backend/cmd/server/main.go | 6 +- backend/internal/domain/auth/handler.go | 28 +-- backend/internal/domain/cuisine/handler.go | 17 +- backend/internal/domain/diary/handler.go | 52 +++--- backend/internal/domain/dish/handler.go | 25 +-- backend/internal/domain/dish/repository.go | 15 +- backend/internal/domain/home/handler.go | 48 +++--- backend/internal/domain/menu/handler.go | 104 ++++++------ backend/internal/domain/product/handler.go | 25 +-- backend/internal/domain/recipe/handler.go | 18 +- .../internal/domain/recognition/handler.go | 48 +++--- backend/internal/domain/recognition/sse.go | 6 +- .../internal/domain/recommendation/handler.go | 26 +-- .../internal/domain/savedrecipe/handler.go | 44 ++--- backend/internal/domain/tag/handler.go | 17 +- backend/internal/domain/units/handler.go | 17 +- backend/internal/domain/user/handler.go | 22 ++- .../internal/domain/userproduct/handler.go | 42 ++--- backend/internal/infra/reqlog/handler.go | 42 +++++ .../migrations/003_add_menu_recipe_source.sql | 5 + .../migrations/004_rename_recipe_products.sql | 27 +++ client/lib/features/home/home_screen.dart | 159 +++++++++++++++++- client/lib/l10n/app_ar.arb | 8 +- client/lib/l10n/app_de.arb | 8 +- client/lib/l10n/app_en.arb | 6 +- client/lib/l10n/app_es.arb | 8 +- client/lib/l10n/app_fr.arb | 8 +- client/lib/l10n/app_hi.arb | 8 +- client/lib/l10n/app_it.arb | 8 +- client/lib/l10n/app_ja.arb | 8 +- client/lib/l10n/app_ko.arb | 8 +- client/lib/l10n/app_localizations.dart | 24 +++ client/lib/l10n/app_localizations_ar.dart | 17 +- client/lib/l10n/app_localizations_de.dart | 17 +- client/lib/l10n/app_localizations_en.dart | 13 ++ client/lib/l10n/app_localizations_es.dart | 17 +- client/lib/l10n/app_localizations_fr.dart | 17 +- client/lib/l10n/app_localizations_hi.dart | 17 +- client/lib/l10n/app_localizations_it.dart | 17 +- client/lib/l10n/app_localizations_ja.dart | 16 +- client/lib/l10n/app_localizations_ko.dart | 16 +- client/lib/l10n/app_localizations_pt.dart | 17 +- client/lib/l10n/app_localizations_ru.dart | 13 ++ client/lib/l10n/app_localizations_zh.dart | 16 +- client/lib/l10n/app_pt.arb | 8 +- client/lib/l10n/app_ru.arb | 6 +- client/lib/l10n/app_zh.arb | 8 +- client/pubspec.lock | 20 +-- 49 files changed, 824 insertions(+), 299 deletions(-) create mode 100644 backend/internal/infra/reqlog/handler.go create mode 100644 backend/migrations/003_add_menu_recipe_source.sql create mode 100644 backend/migrations/004_rename_recipe_products.sql diff --git a/.gitignore b/.gitignore index 7e4a6bb..73181ef 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ client/ios/Runner/GoogleService-Info.plist # Backend backend/.env +backend/server backend/importoff backend/openfoodfacts-products.jsonl backend/openfoodfacts-products.jsonl.gz diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 44370c2..a73362a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,12 +13,14 @@ import ( "github.com/food-ai/backend/internal/infra/config" "github.com/food-ai/backend/internal/infra/database" "github.com/food-ai/backend/internal/infra/locale" + "github.com/food-ai/backend/internal/infra/reqlog" ) func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, - })) + }) + logger := slog.New(reqlog.New(baseHandler)) slog.SetDefault(logger) if runError := run(); runError != nil { diff --git a/backend/internal/domain/auth/handler.go b/backend/internal/domain/auth/handler.go index 42df8f4..113ea29 100644 --- a/backend/internal/domain/auth/handler.go +++ b/backend/internal/domain/auth/handler.go @@ -30,18 +30,18 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req loginRequest 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 } if req.FirebaseToken == "" { - writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required") + writeErrorJSON(w, r, http.StatusBadRequest, "firebase_token is required") return } resp, err := h.service.Login(r.Context(), req.FirebaseToken) if err != nil { - writeErrorJSON(w, http.StatusUnauthorized, "authentication failed") + writeErrorJSON(w, r, http.StatusUnauthorized, "authentication failed") return } @@ -52,18 +52,18 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req refreshRequest 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 } if req.RefreshToken == "" { - writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required") + writeErrorJSON(w, r, http.StatusBadRequest, "refresh_token is required") return } resp, err := h.service.Refresh(r.Context(), req.RefreshToken) if err != nil { - writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token") + writeErrorJSON(w, r, http.StatusUnauthorized, "invalid refresh token") 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } if err := h.service.Logout(r.Context(), userID); err != nil { - writeErrorJSON(w, http.StatusInternalServerError, "logout failed") + writeErrorJSON(w, r, http.StatusInternalServerError, "logout failed") return } @@ -86,14 +86,18 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { } 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.WriteHeader(status) - if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { - slog.Error("failed to write error response", "err", err) + if encodeErr := json.NewEncoder(w).Encode(errorResponse{ + Error: msg, + RequestID: middleware.RequestIDFromCtx(r.Context()), + }); encodeErr != nil { + slog.ErrorContext(r.Context(), "failed to write error response", "err", encodeErr) } } diff --git a/backend/internal/domain/cuisine/handler.go b/backend/internal/domain/cuisine/handler.go index be10937..b99a254 100644 --- a/backend/internal/domain/cuisine/handler.go +++ b/backend/internal/domain/cuisine/handler.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/food-ai/backend/internal/infra/locale" + "github.com/food-ai/backend/internal/infra/middleware" "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 ORDER BY c.sort_order`, lang) if queryError != nil { - slog.Error("list cuisines", "err", queryError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines") + slog.ErrorContext(request.Context(), "list cuisines", "err", queryError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines") return } defer rows.Close() @@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc { for rows.Next() { var item cuisineItem if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil { - slog.Error("scan cuisine row", "err", scanError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines") + slog.ErrorContext(request.Context(), "scan cuisine row", "err", scanError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines") return } items = append(items, item) } if rowsError := rows.Err(); rowsError != nil { - slog.Error("iterate cuisine rows", "err", rowsError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines") + slog.ErrorContext(request.Context(), "iterate cuisine rows", "err", rowsError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines") 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.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())}) } diff --git a/backend/internal/domain/diary/handler.go b/backend/internal/domain/diary/handler.go index 9a17d64..d1db259 100644 --- a/backend/internal/domain/diary/handler.go +++ b/backend/internal/domain/diary/handler.go @@ -45,20 +45,20 @@ func NewHandler(repo DiaryRepository, dishRepo DishRepository, recipeRepo Recipe func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } date := r.URL.Query().Get("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 } entries, listError := h.repo.ListByDate(r.Context(), userID, date) if listError != nil { - slog.Error("list diary by date", "err", listError) - writeError(w, http.StatusInternalServerError, "failed to load diary") + slog.ErrorContext(r.Context(), "list diary by date", "err", listError) + writeError(w, r, http.StatusInternalServerError, "failed to load diary") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } var req CreateRequest 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 } 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 } 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 } @@ -94,8 +94,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { if req.DishID == nil { dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name) if resolveError != nil { - slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError) - writeError(w, http.StatusInternalServerError, "failed to resolve dish") + slog.ErrorContext(r.Context(), "resolve dish for diary entry", "name", req.Name, "err", resolveError) + writeError(w, r, http.StatusInternalServerError, "failed to resolve dish") return } req.DishID = &dishID @@ -104,8 +104,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { if req.RecipeID == nil { recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0) if recipeError != nil { - slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) - writeError(w, http.StatusInternalServerError, "failed to resolve recipe") + slog.ErrorContext(r.Context(), "find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) + writeError(w, r, http.StatusInternalServerError, "failed to resolve recipe") return } 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) if createError != nil { - slog.Error("create diary entry", "err", createError) - writeError(w, http.StatusInternalServerError, "failed to create diary entry") + slog.ErrorContext(r.Context(), "create diary entry", "err", createError) + writeError(w, r, http.StatusInternalServerError, "failed to create diary entry") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } 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) if queryError != nil { - slog.Error("get recent diary items", "err", queryError) - writeError(w, http.StatusInternalServerError, "failed to get recent items") + slog.ErrorContext(r.Context(), "get recent diary items", "err", queryError) + writeError(w, r, http.StatusInternalServerError, "failed to get recent items") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } id := chi.URLParam(r, "id") if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil { if deleteError == ErrNotFound { - writeError(w, http.StatusNotFound, "diary entry not found") + writeError(w, r, http.StatusNotFound, "diary entry not found") return } - slog.Error("delete diary entry", "err", deleteError) - writeError(w, http.StatusInternalServerError, "failed to delete diary entry") + slog.ErrorContext(r.Context(), "delete diary entry", "err", deleteError) + writeError(w, r, http.StatusInternalServerError, "failed to delete diary entry") return } w.WriteHeader(http.StatusNoContent) } 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.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) { diff --git a/backend/internal/domain/dish/handler.go b/backend/internal/domain/dish/handler.go index 55be158..94addb6 100644 --- a/backend/internal/domain/dish/handler.go +++ b/backend/internal/domain/dish/handler.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/go-chi/chi/v5" + "github.com/food-ai/backend/internal/infra/middleware" ) // 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) if searchError != nil { - slog.Error("search dishes", "err", searchError) - writeError(w, http.StatusInternalServerError, "failed to search dishes") + slog.ErrorContext(r.Context(), "search dishes", "err", searchError) + writeError(w, r, http.StatusInternalServerError, "failed to search dishes") return } 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) { dishes, err := h.repo.List(r.Context()) if err != nil { - slog.Error("list dishes", "err", err) - writeError(w, http.StatusInternalServerError, "failed to list dishes") + slog.ErrorContext(r.Context(), "list dishes", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to list dishes") return } if dishes == nil { @@ -63,12 +64,12 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dish, err := h.repo.GetByID(r.Context(), id) if err != nil { - slog.Error("get dish", "id", id, "err", err) - writeError(w, http.StatusInternalServerError, "failed to get dish") + slog.ErrorContext(r.Context(), "get dish", "id", id, "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to get dish") return } if dish == nil { - writeError(w, http.StatusNotFound, "dish not found") + writeError(w, r, http.StatusNotFound, "dish not found") return } writeJSON(w, http.StatusOK, dish) @@ -77,13 +78,17 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { // --- helpers --- 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.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) { diff --git a/backend/internal/domain/dish/repository.go b/backend/internal/domain/dish/repository.go index 9a4639d..2cfc777 100644 --- a/backend/internal/domain/dish/repository.go +++ b/backend/internal/domain/dish/repository.go @@ -312,13 +312,20 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st 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 { - 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`, dishID, slug, - ); err != nil { - return "", fmt.Errorf("insert dish tag %s: %w", slug, err) + ); insertErr != nil { + return "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr) } } diff --git a/backend/internal/domain/home/handler.go b/backend/internal/domain/home/handler.go index e317f66..5f790c9 100644 --- a/backend/internal/domain/home/handler.go +++ b/backend/internal/domain/home/handler.go @@ -9,6 +9,7 @@ import ( "time" "github.com/food-ai/backend/internal/infra/middleware" + "github.com/food-ai/backend/internal/infra/locale" "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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } @@ -69,7 +70,7 @@ func (h *Handler) getDailyGoal(ctx context.Context, userID string) int { userID, ).Scan(&goal) 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 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. // 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 { + lang := locale.FromContext(ctx) const q = ` SELECT mi.meal_type, - sr.title, - sr.image_url, - (sr.nutrition->>'calories')::float + COALESCE(dt.name, d.name) AS title, + d.image_url, + rec.calories_per_serving FROM menu_plans mp 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 AND mp.week_start::text = $2 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 { - slog.Warn("home: get today plan", "err", err) + slog.WarnContext(ctx, "home: get today plan", "err", err) return defaultPlan() } defer rows.Close() @@ -175,7 +179,7 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring userID, ) if err != nil { - slog.Warn("home: get expiring soon", "err", err) + slog.WarnContext(ctx, "home: get expiring soon", "err", err) return nil } defer rows.Close() @@ -197,19 +201,25 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring 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 { + lang := locale.FromContext(ctx) rows, err := h.pool.Query(ctx, ` - SELECT id, title, COALESCE(image_url, ''), - (nutrition->>'calories')::float - FROM saved_recipes - WHERE user_id = $1 AND source = 'recommendation' - ORDER BY saved_at DESC + SELECT rec.id, + COALESCE(dt.name, d.name), + COALESCE(d.image_url, ''), + rec.calories_per_serving + 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`, - userID, + userID, lang, ) if err != nil { - slog.Warn("home: get recommendations", "err", err) + slog.WarnContext(ctx, "home: get recommendations", "err", err) return nil } defer rows.Close() @@ -238,10 +248,10 @@ func mondayOfISOWeek(year, week int) time.Time { 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.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) { diff --git a/backend/internal/domain/menu/handler.go b/backend/internal/domain/menu/handler.go index 512b7d9..7c760a1 100644 --- a/backend/internal/domain/menu/handler.go +++ b/backend/internal/domain/menu/handler.go @@ -80,20 +80,20 @@ func NewHandler( func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) 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 } plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) if err != nil { - slog.Error("get menu", "err", err) - writeError(w, http.StatusInternalServerError, "failed to load menu") + slog.ErrorContext(r.Context(), "get menu", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to load menu") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } @@ -122,15 +122,15 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { weekStart, err := ResolveWeekStart(body.Week) if err != nil { - writeError(w, http.StatusBadRequest, "invalid week parameter") + writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } // Load user profile. u, err := h.userLoader.GetByID(r.Context(), userID) if err != nil { - slog.Error("load user for menu generation", "err", err) - writeError(w, http.StatusInternalServerError, "failed to load user profile") + slog.ErrorContext(r.Context(), "load user for menu generation", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to load user profile") return } @@ -144,8 +144,8 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { // Generate 7-day plan via Gemini. days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq) if err != nil { - slog.Error("generate menu", "user_id", userID, "err", err) - writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again") + slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", err) + writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again") return } @@ -166,7 +166,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { defer wg.Done() url, err := h.pexels.SearchPhoto(r.Context(), query) 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() 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 { recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe)) if err != nil { - slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err) - writeError(w, http.StatusInternalServerError, "failed to save recipes") + slog.ErrorContext(r.Context(), "save recipe for menu", "title", meal.Recipe.Title, "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to save recipes") return } 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. planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) if err != nil { - slog.Error("save menu plan", "err", err) - writeError(w, http.StatusInternalServerError, "failed to save menu plan") + slog.ErrorContext(r.Context(), "save menu plan", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") return } // Auto-generate shopping list. if shoppingItems, err := h.buildShoppingList(r.Context(), planID); 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. plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) if err != nil || plan == nil { - slog.Error("load generated menu", "err", err, "plan_nil", plan == nil) - writeError(w, http.StatusInternalServerError, "failed to load generated menu") + slog.ErrorContext(r.Context(), "load generated menu", "err", err, "plan_nil", plan == nil) + writeError(w, r, http.StatusInternalServerError, "failed to load generated menu") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } @@ -248,17 +248,17 @@ func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) { RecipeID string `json:"recipe_id"` } 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 } if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil { if err == ErrNotFound { - writeError(w, http.StatusNotFound, "menu item not found") + writeError(w, r, http.StatusNotFound, "menu item not found") return } - slog.Error("update menu item", "err", err) - writeError(w, http.StatusInternalServerError, "failed to update menu item") + slog.ErrorContext(r.Context(), "update menu item", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to update menu item") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } itemID := chi.URLParam(r, "id") if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil { if err == ErrNotFound { - writeError(w, http.StatusNotFound, "menu item not found") + writeError(w, r, http.StatusNotFound, "menu item not found") return } - slog.Error("delete menu item", "err", err) - writeError(w, http.StatusInternalServerError, "failed to delete menu item") + slog.ErrorContext(r.Context(), "delete menu item", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to delete menu item") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } @@ -304,31 +304,31 @@ func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) { weekStart, err := ResolveWeekStart(body.Week) if err != nil { - writeError(w, http.StatusBadRequest, "invalid week parameter") + writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { 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 } - slog.Error("get plan id", "err", err) - writeError(w, http.StatusInternalServerError, "failed to find menu plan") + slog.ErrorContext(r.Context(), "get plan id", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to find menu plan") return } items, err := h.buildShoppingList(r.Context(), planID) if err != nil { - slog.Error("build shopping list", "err", err) - writeError(w, http.StatusInternalServerError, "failed to build shopping list") + slog.ErrorContext(r.Context(), "build shopping list", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to build shopping list") return } if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil { - slog.Error("upsert shopping list", "err", err) - writeError(w, http.StatusInternalServerError, "failed to save shopping list") + slog.ErrorContext(r.Context(), "upsert shopping list", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to save shopping list") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid week parameter") + writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } @@ -354,13 +354,13 @@ func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, []ShoppingItem{}) return } - writeError(w, http.StatusInternalServerError, "failed to find menu plan") + writeError(w, r, http.StatusInternalServerError, "failed to find menu plan") return } items, err := h.repo.GetShoppingList(r.Context(), userID, planID) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to load shopping list") + writeError(w, r, http.StatusInternalServerError, "failed to load shopping list") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") + writeError(w, r, http.StatusUnauthorized, "unauthorized") return } indexStr := chi.URLParam(r, "index") var index int 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 } @@ -388,25 +388,25 @@ func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) { Checked bool `json:"checked"` } 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 } weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid week parameter") + writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { - writeError(w, http.StatusNotFound, "menu plan not found") + writeError(w, r, http.StatusNotFound, "menu plan not found") return } if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil { - slog.Error("toggle shopping item", "err", err) - writeError(w, http.StatusInternalServerError, "failed to update item") + slog.ErrorContext(r.Context(), "toggle shopping item", "err", err) + writeError(w, r, http.StatusInternalServerError, "failed to update item") return } w.WriteHeader(http.StatusNoContent) @@ -568,13 +568,17 @@ func mondayOfISOWeek(year, week int) time.Time { } 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.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) { diff --git a/backend/internal/domain/product/handler.go b/backend/internal/domain/product/handler.go index 00871a6..8dec84f 100644 --- a/backend/internal/domain/product/handler.go +++ b/backend/internal/domain/product/handler.go @@ -8,6 +8,7 @@ import ( "strconv" "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. @@ -51,7 +52,7 @@ func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http products, searchError := handler.repo.Search(request.Context(), query, limit) 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.WriteHeader(http.StatusInternalServerError) _, _ = 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) { barcode := chi.URLParam(request, "barcode") if barcode == "" { - writeErrorJSON(responseWriter, http.StatusBadRequest, "barcode is required") + writeErrorJSON(responseWriter, request, http.StatusBadRequest, "barcode is required") return } // Check the local catalog first. catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode) if lookupError != nil { - slog.Error("lookup product by barcode", "barcode", barcode, "err", lookupError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "lookup failed") + slog.ErrorContext(request.Context(), "lookup product by barcode", "barcode", barcode, "err", lookupError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "lookup failed") return } if catalogProduct != nil { @@ -90,15 +91,15 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request // Not in catalog — fetch from Open Food Facts. fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode) if fetchError != nil { - slog.Warn("open food facts fetch failed", "barcode", barcode, "err", fetchError) - writeErrorJSON(responseWriter, http.StatusNotFound, "product not found") + slog.WarnContext(request.Context(), "open food facts fetch failed", "barcode", barcode, "err", fetchError) + writeErrorJSON(responseWriter, request, http.StatusNotFound, "product not found") return } // Persist the fetched product so subsequent lookups are served from the DB. savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct) 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. writeJSON(responseWriter, http.StatusOK, fetchedProduct) return @@ -107,13 +108,17 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request } 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.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) { diff --git a/backend/internal/domain/recipe/handler.go b/backend/internal/domain/recipe/handler.go index ae15f55..fca9e5a 100644 --- a/backend/internal/domain/recipe/handler.go +++ b/backend/internal/domain/recipe/handler.go @@ -23,32 +23,36 @@ func NewHandler(repo *Repository) *Handler { func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } id := chi.URLParam(r, "id") rec, err := h.repo.GetByID(r.Context(), id) if err != nil { - slog.Error("get recipe", "id", id, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe") + slog.ErrorContext(r.Context(), "get recipe", "id", id, "err", err) + writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get recipe") return } if rec == nil { - writeErrorJSON(w, http.StatusNotFound, "recipe not found") + writeErrorJSON(w, r, http.StatusNotFound, "recipe not found") return } writeJSON(w, http.StatusOK, rec) } 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.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) { diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index 03d53dc..4ce8567 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -125,15 +125,15 @@ func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, req var req imageRequest 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 } lang := locale.FromContext(request.Context()) result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang) if recognizeError != nil { - slog.Error("recognize receipt", "err", recognizeError) - writeErrorJSON(responseWriter, http.StatusServiceUnavailable, "recognition failed, please try again") + slog.ErrorContext(request.Context(), "recognize receipt", "err", recognizeError) + writeErrorJSON(responseWriter, request, http.StatusServiceUnavailable, "recognition failed, please try again") return } @@ -149,7 +149,7 @@ func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, req func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) { var req imagesRequest 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 } if len(req.Images) > 3 { @@ -165,7 +165,7 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re defer wg.Done() items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang) 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 } 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) { var req recognizeDishRequest 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 } @@ -202,8 +202,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques TargetMealType: req.TargetMealType, } if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil { - slog.Error("insert recognition job", "err", insertError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create job") + slog.ErrorContext(request.Context(), "insert recognition job", "err", insertError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create job") return } @@ -217,8 +217,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques topic = TopicPaid } if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil { - slog.Error("publish recognition job", "job_id", job.ID, "err", publishError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to enqueue job") + slog.ErrorContext(request.Context(), "publish recognition job", "job_id", job.ID, "err", publishError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to enqueue job") return } @@ -236,8 +236,8 @@ func (handler *Handler) ListTodayJobs(responseWriter http.ResponseWriter, reques summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID) if listError != nil { - slog.Error("list today unlinked jobs", "err", listError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs") + slog.ErrorContext(request.Context(), "list today unlinked jobs", "err", listError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs") return } @@ -254,8 +254,8 @@ func (handler *Handler) ListAllJobs(responseWriter http.ResponseWriter, request summaries, listError := handler.jobRepo.ListAll(request.Context(), userID) if listError != nil { - slog.Error("list all jobs", "err", listError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs") + slog.ErrorContext(request.Context(), "list all jobs", "err", listError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs") return } @@ -277,11 +277,11 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http job, fetchError := handler.jobRepo.GetJobByID(request.Context(), jobID) if fetchError != nil { - writeErrorJSON(responseWriter, http.StatusNotFound, "job not found") + writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found") return } if job.UserID != userID { - writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden") + writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden") return } 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) 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 { @@ -325,7 +325,7 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt } else { classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name) 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 { saved := handler.saveClassification(ctx, classification) if saved != nil { @@ -361,7 +361,7 @@ func (handler *Handler) saveClassification(ctx context.Context, classification * saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct) 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 } @@ -417,13 +417,17 @@ func intPtr(n int) *int { // --------------------------------------------------------------------------- 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.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) { diff --git a/backend/internal/domain/recognition/sse.go b/backend/internal/domain/recognition/sse.go index 146e6ef..4e43651 100644 --- a/backend/internal/domain/recognition/sse.go +++ b/backend/internal/domain/recognition/sse.go @@ -147,17 +147,17 @@ func (broker *SSEBroker) ServeSSE(responseWriter http.ResponseWriter, request *h job, fetchError := broker.jobRepo.GetJobByID(request.Context(), jobID) if fetchError != nil { - writeErrorJSON(responseWriter, http.StatusNotFound, "job not found") + writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found") return } if job.UserID != userID { - writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden") + writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden") return } flusher, supported := responseWriter.(http.Flusher) if !supported { - writeErrorJSON(responseWriter, http.StatusInternalServerError, "streaming not supported") + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "streaming not supported") return } diff --git a/backend/internal/domain/recommendation/handler.go b/backend/internal/domain/recommendation/handler.go index ef1cb3c..d305b27 100644 --- a/backend/internal/domain/recommendation/handler.go +++ b/backend/internal/domain/recommendation/handler.go @@ -62,7 +62,7 @@ func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoade func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } @@ -75,8 +75,8 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { u, err := h.userLoader.GetByID(r.Context(), userID) if err != nil { - slog.Error("load user for recommendations", "user_id", userID, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile") + slog.ErrorContext(r.Context(), "load user for recommendations", "user_id", userID, "err", err) + writeErrorJSON(w, r, http.StatusInternalServerError, "failed to load user profile") 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 { req.AvailableProducts = products } 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) if err != nil { - slog.Error("generate recipes", "user_id", userID, "err", err) - writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again") + slog.ErrorContext(r.Context(), "generate recipes", "user_id", userID, "err", err) + writeErrorJSON(w, r, http.StatusServiceUnavailable, "recipe generation failed, please try again") return } @@ -104,7 +104,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { defer wg.Done() imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery) 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 }(i) @@ -139,14 +139,18 @@ func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest { } 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.WriteHeader(status) - if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { - slog.Error("write error response", "err", err) + if encodeErr := json.NewEncoder(w).Encode(errorResponse{ + Error: msg, + RequestID: middleware.RequestIDFromCtx(r.Context()), + }); encodeErr != nil { + slog.ErrorContext(r.Context(), "write error response", "err", encodeErr) } } diff --git a/backend/internal/domain/savedrecipe/handler.go b/backend/internal/domain/savedrecipe/handler.go index f60c147..249982b 100644 --- a/backend/internal/domain/savedrecipe/handler.go +++ b/backend/internal/domain/savedrecipe/handler.go @@ -35,25 +35,25 @@ func NewHandler(repo SavedRecipeRepository) *Handler { func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) var req SaveRequest 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 } 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 } rec, err := h.repo.Save(r.Context(), userID, req) if err != nil { - slog.Error("save recipe", "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe") + slog.ErrorContext(r.Context(), "save recipe", "err", err) + writeErrorJSON(w, r, http.StatusInternalServerError, "failed to save recipe") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } recipes, err := h.repo.List(r.Context(), userID) if err != nil { - slog.Error("list saved recipes", "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes") + slog.ErrorContext(r.Context(), "list saved recipes", "err", err) + writeErrorJSON(w, r, http.StatusInternalServerError, "failed to list saved recipes") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } id := chi.URLParam(r, "id") rec, err := h.repo.GetByID(r.Context(), userID, id) if err != nil { - slog.Error("get saved recipe", "id", id, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe") + slog.ErrorContext(r.Context(), "get saved recipe", "id", id, "err", err) + writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get saved recipe") return } if rec == nil { - writeErrorJSON(w, http.StatusNotFound, "recipe not found") + writeErrorJSON(w, r, http.StatusNotFound, "recipe not found") return } 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } id := chi.URLParam(r, "id") if err := h.repo.Delete(r.Context(), userID, id); err != nil { if errors.Is(err, ErrNotFound) { - writeErrorJSON(w, http.StatusNotFound, "recipe not found") + writeErrorJSON(w, r, http.StatusNotFound, "recipe not found") return } - slog.Error("delete saved recipe", "id", id, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe") + slog.ErrorContext(r.Context(), "delete saved recipe", "id", id, "err", err) + writeErrorJSON(w, r, http.StatusInternalServerError, "failed to delete recipe") return } w.WriteHeader(http.StatusNoContent) } 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.WriteHeader(status) - if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { - slog.Error("write error response", "err", err) + if encodeErr := json.NewEncoder(w).Encode(errorResponse{ + Error: msg, + RequestID: middleware.RequestIDFromCtx(r.Context()), + }); encodeErr != nil { + slog.ErrorContext(r.Context(), "write error response", "err", encodeErr) } } diff --git a/backend/internal/domain/tag/handler.go b/backend/internal/domain/tag/handler.go index 46e0084..6713ce6 100644 --- a/backend/internal/domain/tag/handler.go +++ b/backend/internal/domain/tag/handler.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/food-ai/backend/internal/infra/locale" + "github.com/food-ai/backend/internal/infra/middleware" "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 ORDER BY t.sort_order`, lang) if queryError != nil { - slog.Error("list tags", "err", queryError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags") + slog.ErrorContext(request.Context(), "list tags", "err", queryError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags") return } defer rows.Close() @@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc { for rows.Next() { var item tagItem if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil { - slog.Error("scan tag row", "err", scanError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags") + slog.ErrorContext(request.Context(), "scan tag row", "err", scanError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags") return } items = append(items, item) } if rowsError := rows.Err(); rowsError != nil { - slog.Error("iterate tag rows", "err", rowsError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags") + slog.ErrorContext(request.Context(), "iterate tag rows", "err", rowsError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags") 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.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())}) } diff --git a/backend/internal/domain/units/handler.go b/backend/internal/domain/units/handler.go index e6fef45..7c8564b 100644 --- a/backend/internal/domain/units/handler.go +++ b/backend/internal/domain/units/handler.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/food-ai/backend/internal/infra/locale" + "github.com/food-ai/backend/internal/infra/middleware" "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 ORDER BY u.sort_order`, lang) if queryError != nil { - slog.Error("list units", "err", queryError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units") + slog.ErrorContext(request.Context(), "list units", "err", queryError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units") return } defer rows.Close() @@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc { for rows.Next() { var item unitItem if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil { - slog.Error("scan unit row", "err", scanError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units") + slog.ErrorContext(request.Context(), "scan unit row", "err", scanError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units") return } items = append(items, item) } if rowsError := rows.Err(); rowsError != nil { - slog.Error("iterate unit rows", "err", rowsError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units") + slog.ErrorContext(request.Context(), "iterate unit rows", "err", rowsError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units") 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.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())}) } diff --git a/backend/internal/domain/user/handler.go b/backend/internal/domain/user/handler.go index 9ef0a16..c4bc191 100644 --- a/backend/internal/domain/user/handler.go +++ b/backend/internal/domain/user/handler.go @@ -21,13 +21,13 @@ func NewHandler(service *Service) *Handler { func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } u, err := h.service.GetProfile(r.Context(), userID) if err != nil { - writeErrorJSON(w, http.StatusNotFound, "user not found") + writeErrorJSON(w, r, http.StatusNotFound, "user not found") 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) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { - writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req UpdateProfileRequest 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 } u, err := h.service.UpdateProfile(r.Context(), userID, req) if err != nil { - writeErrorJSON(w, http.StatusBadRequest, err.Error()) + writeErrorJSON(w, r, http.StatusBadRequest, err.Error()) return } @@ -58,14 +58,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } 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.WriteHeader(status) - if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { - slog.Error("failed to write error response", "err", err) + if encodeErr := json.NewEncoder(w).Encode(errorResponse{ + Error: msg, + RequestID: middleware.RequestIDFromCtx(r.Context()), + }); encodeErr != nil { + slog.ErrorContext(r.Context(), "failed to write error response", "err", encodeErr) } } diff --git a/backend/internal/domain/userproduct/handler.go b/backend/internal/domain/userproduct/handler.go index 7003598..dc6d25d 100644 --- a/backend/internal/domain/userproduct/handler.go +++ b/backend/internal/domain/userproduct/handler.go @@ -35,8 +35,8 @@ func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.R userID := middleware.UserIDFromCtx(request.Context()) userProducts, listError := handler.repo.List(request.Context(), userID) if listError != nil { - slog.Error("list user products", "user_id", userID, "err", listError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list user products") + slog.ErrorContext(request.Context(), "list user products", "user_id", userID, "err", listError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list user products") return } if userProducts == nil { @@ -50,18 +50,18 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http userID := middleware.UserIDFromCtx(request.Context()) var req CreateRequest 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 } if req.Name == "" { - writeErrorJSON(responseWriter, http.StatusBadRequest, "name is required") + writeErrorJSON(responseWriter, request, http.StatusBadRequest, "name is required") return } userProduct, createError := handler.repo.Create(request.Context(), userID, req) if createError != nil { - slog.Error("create user product", "user_id", userID, "err", createError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user product") + slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product") return } writeJSON(responseWriter, http.StatusCreated, userProduct) @@ -72,7 +72,7 @@ func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request userID := middleware.UserIDFromCtx(request.Context()) var items []CreateRequest 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 } 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) if batchError != nil { - slog.Error("batch create user products", "user_id", userID, "err", batchError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user products") + slog.ErrorContext(request.Context(), "batch create user products", "user_id", userID, "err", batchError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user products") return } writeJSON(responseWriter, http.StatusCreated, userProducts) @@ -96,18 +96,18 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http var req UpdateRequest 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 } userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req) if errors.Is(updateError, ErrNotFound) { - writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found") + writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found") return } if updateError != nil { - slog.Error("update user product", "id", id, "err", updateError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to update user product") + slog.ErrorContext(request.Context(), "update user product", "id", id, "err", updateError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to update user product") return } 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 errors.Is(deleteError, ErrNotFound) { - writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found") + writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found") return } - slog.Error("delete user product", "id", id, "err", deleteError) - writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to delete user product") + slog.ErrorContext(request.Context(), "delete user product", "id", id, "err", deleteError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete user product") return } responseWriter.WriteHeader(http.StatusNoContent) } 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.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) { diff --git a/backend/internal/infra/reqlog/handler.go b/backend/internal/infra/reqlog/handler.go new file mode 100644 index 0000000..b1d2dc3 --- /dev/null +++ b/backend/internal/infra/reqlog/handler.go @@ -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)} +} diff --git a/backend/migrations/003_add_menu_recipe_source.sql b/backend/migrations/003_add_menu_recipe_source.sql new file mode 100644 index 0000000..b56d805 --- /dev/null +++ b/backend/migrations/003_add_menu_recipe_source.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TYPE recipe_source ADD VALUE IF NOT EXISTS 'menu'; + +-- +goose Down +-- Cannot remove enum values in PostgreSQL diff --git a/backend/migrations/004_rename_recipe_products.sql b/backend/migrations/004_rename_recipe_products.sql new file mode 100644 index 0000000..963f977 --- /dev/null +++ b/backend/migrations/004_rename_recipe_products.sql @@ -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; diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index d917233..5de241f 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -96,7 +96,7 @@ class HomeScreen extends ConsumerWidget { ), const SizedBox(height: 16), if (isFutureDate) - _PlanningBanner(dateString: dateString) + _FutureDayHeader(dateString: dateString) else ...[ _CaloriesCard( 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) ──────────────────────────── class _PlanningBanner extends StatelessWidget { diff --git a/client/lib/l10n/app_ar.arb b/client/lib/l10n/app_ar.arb index 99e0031..ce07ba4 100644 --- a/client/lib/l10n/app_ar.arb +++ b/client/lib/l10n/app_ar.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "وضع علامة كمأكول", + "plannedMealLabel": "مخطط", + "generateWeekLabel": "تخطيط الأسبوع", + "generateWeekSubtitle": "سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع", + "generatingMenu": "جارٍ إنشاء القائمة...", + "weekPlannedLabel": "تم تخطيط الأسبوع" } diff --git a/client/lib/l10n/app_de.arb b/client/lib/l10n/app_de.arb index 8677da7..760413c 100644 --- a/client/lib/l10n/app_de.arb +++ b/client/lib/l10n/app_de.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "Als gegessen markieren", + "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" } diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index 8c55c9b..178a1be 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -129,5 +129,9 @@ } }, "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" } diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index 7a4b253..f142cbb 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "Marcar como comido", + "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" } diff --git a/client/lib/l10n/app_fr.arb b/client/lib/l10n/app_fr.arb index ccc12d8..7aeed43 100644 --- a/client/lib/l10n/app_fr.arb +++ b/client/lib/l10n/app_fr.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "Marquer comme mangé", + "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" } diff --git a/client/lib/l10n/app_hi.arb b/client/lib/l10n/app_hi.arb index d81e8bd..69d7019 100644 --- a/client/lib/l10n/app_hi.arb +++ b/client/lib/l10n/app_hi.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "खाया हुआ चिह्नित करें", + "plannedMealLabel": "नियोजित", + "generateWeekLabel": "सप्ताह की योजना बनाएं", + "generateWeekSubtitle": "AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा", + "generatingMenu": "मेनू बना रहे हैं...", + "weekPlannedLabel": "सप्ताह की योजना बनाई गई" } diff --git a/client/lib/l10n/app_it.arb b/client/lib/l10n/app_it.arb index 5a590a9..7249e6f 100644 --- a/client/lib/l10n/app_it.arb +++ b/client/lib/l10n/app_it.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "Segna come mangiato", + "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" } diff --git a/client/lib/l10n/app_ja.arb b/client/lib/l10n/app_ja.arb index 6000f1d..fd3c146 100644 --- a/client/lib/l10n/app_ja.arb +++ b/client/lib/l10n/app_ja.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "食べた印をつける", + "plannedMealLabel": "予定済み", + "generateWeekLabel": "週を計画する", + "generateWeekSubtitle": "AIが一週間の朝食・昼食・夕食のメニューを作成します", + "generatingMenu": "メニューを生成中...", + "weekPlannedLabel": "週の計画済み" } diff --git a/client/lib/l10n/app_ko.arb b/client/lib/l10n/app_ko.arb index 151f535..c49787e 100644 --- a/client/lib/l10n/app_ko.arb +++ b/client/lib/l10n/app_ko.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "먹은 것으로 표시", + "plannedMealLabel": "계획됨", + "generateWeekLabel": "주간 계획하기", + "generateWeekSubtitle": "AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다", + "generatingMenu": "메뉴 생성 중...", + "weekPlannedLabel": "주간 계획 완료" } diff --git a/client/lib/l10n/app_localizations.dart b/client/lib/l10n/app_localizations.dart index 79cfa9d..e11d961 100644 --- a/client/lib/l10n/app_localizations.dart +++ b/client/lib/l10n/app_localizations.dart @@ -807,6 +807,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Planned'** 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 diff --git a/client/lib/l10n/app_localizations_ar.dart b/client/lib/l10n/app_localizations_ar.dart index d440706..28fb97b 100644 --- a/client/lib/l10n/app_localizations_ar.dart +++ b/client/lib/l10n/app_localizations_ar.dart @@ -355,8 +355,21 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'وضع علامة كمأكول'; @override - String get plannedMealLabel => ''; + String get plannedMealLabel => 'مخطط'; + + @override + String get generateWeekLabel => 'تخطيط الأسبوع'; + + @override + String get generateWeekSubtitle => + 'سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع'; + + @override + String get generatingMenu => 'جارٍ إنشاء القائمة...'; + + @override + String get weekPlannedLabel => 'تم تخطيط الأسبوع'; } diff --git a/client/lib/l10n/app_localizations_de.dart b/client/lib/l10n/app_localizations_de.dart index 4c126cd..1eeac3d 100644 --- a/client/lib/l10n/app_localizations_de.dart +++ b/client/lib/l10n/app_localizations_de.dart @@ -357,8 +357,21 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'Als gegessen markieren'; @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'; } diff --git a/client/lib/l10n/app_localizations_en.dart b/client/lib/l10n/app_localizations_en.dart index dea56e4..ce7eb31 100644 --- a/client/lib/l10n/app_localizations_en.dart +++ b/client/lib/l10n/app_localizations_en.dart @@ -359,4 +359,17 @@ class AppLocalizationsEn extends AppLocalizations { @override 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'; } diff --git a/client/lib/l10n/app_localizations_es.dart b/client/lib/l10n/app_localizations_es.dart index f1bfcdd..75725ca 100644 --- a/client/lib/l10n/app_localizations_es.dart +++ b/client/lib/l10n/app_localizations_es.dart @@ -357,8 +357,21 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'Marcar como comido'; @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'; } diff --git a/client/lib/l10n/app_localizations_fr.dart b/client/lib/l10n/app_localizations_fr.dart index 918964d..53a70d5 100644 --- a/client/lib/l10n/app_localizations_fr.dart +++ b/client/lib/l10n/app_localizations_fr.dart @@ -358,8 +358,21 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'Marquer comme mangé'; @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'; } diff --git a/client/lib/l10n/app_localizations_hi.dart b/client/lib/l10n/app_localizations_hi.dart index 2f3ef33..e66ac9d 100644 --- a/client/lib/l10n/app_localizations_hi.dart +++ b/client/lib/l10n/app_localizations_hi.dart @@ -356,8 +356,21 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'खाया हुआ चिह्नित करें'; @override - String get plannedMealLabel => ''; + String get plannedMealLabel => 'नियोजित'; + + @override + String get generateWeekLabel => 'सप्ताह की योजना बनाएं'; + + @override + String get generateWeekSubtitle => + 'AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा'; + + @override + String get generatingMenu => 'मेनू बना रहे हैं...'; + + @override + String get weekPlannedLabel => 'सप्ताह की योजना बनाई गई'; } diff --git a/client/lib/l10n/app_localizations_it.dart b/client/lib/l10n/app_localizations_it.dart index 7a7f200..153c465 100644 --- a/client/lib/l10n/app_localizations_it.dart +++ b/client/lib/l10n/app_localizations_it.dart @@ -357,8 +357,21 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'Segna come mangiato'; @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'; } diff --git a/client/lib/l10n/app_localizations_ja.dart b/client/lib/l10n/app_localizations_ja.dart index 166c3c3..22a0fdb 100644 --- a/client/lib/l10n/app_localizations_ja.dart +++ b/client/lib/l10n/app_localizations_ja.dart @@ -354,8 +354,20 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => '食べた印をつける'; @override - String get plannedMealLabel => ''; + String get plannedMealLabel => '予定済み'; + + @override + String get generateWeekLabel => '週を計画する'; + + @override + String get generateWeekSubtitle => 'AIが一週間の朝食・昼食・夕食のメニューを作成します'; + + @override + String get generatingMenu => 'メニューを生成中...'; + + @override + String get weekPlannedLabel => '週の計画済み'; } diff --git a/client/lib/l10n/app_localizations_ko.dart b/client/lib/l10n/app_localizations_ko.dart index f7351d5..c3256b6 100644 --- a/client/lib/l10n/app_localizations_ko.dart +++ b/client/lib/l10n/app_localizations_ko.dart @@ -354,8 +354,20 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => '먹은 것으로 표시'; @override - String get plannedMealLabel => ''; + String get plannedMealLabel => '계획됨'; + + @override + String get generateWeekLabel => '주간 계획하기'; + + @override + String get generateWeekSubtitle => 'AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다'; + + @override + String get generatingMenu => '메뉴 생성 중...'; + + @override + String get weekPlannedLabel => '주간 계획 완료'; } diff --git a/client/lib/l10n/app_localizations_pt.dart b/client/lib/l10n/app_localizations_pt.dart index e289d3f..ed7fae5 100644 --- a/client/lib/l10n/app_localizations_pt.dart +++ b/client/lib/l10n/app_localizations_pt.dart @@ -357,8 +357,21 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => 'Marcar como comido'; @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'; } diff --git a/client/lib/l10n/app_localizations_ru.dart b/client/lib/l10n/app_localizations_ru.dart index e92b4a2..1a108e8 100644 --- a/client/lib/l10n/app_localizations_ru.dart +++ b/client/lib/l10n/app_localizations_ru.dart @@ -359,4 +359,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get plannedMealLabel => 'Запланировано'; + + @override + String get generateWeekLabel => 'Запланировать неделю'; + + @override + String get generateWeekSubtitle => + 'AI составит меню с завтраком, обедом и ужином на всю неделю'; + + @override + String get generatingMenu => 'Генерируем меню...'; + + @override + String get weekPlannedLabel => 'Неделя запланирована'; } diff --git a/client/lib/l10n/app_localizations_zh.dart b/client/lib/l10n/app_localizations_zh.dart index c3f174f..33e8b89 100644 --- a/client/lib/l10n/app_localizations_zh.dart +++ b/client/lib/l10n/app_localizations_zh.dart @@ -354,8 +354,20 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get markAsEaten => ''; + String get markAsEaten => '标记为已吃'; @override - String get plannedMealLabel => ''; + String get plannedMealLabel => '已计划'; + + @override + String get generateWeekLabel => '规划本周'; + + @override + String get generateWeekSubtitle => 'AI将为整周创建含早餐、午餐和晚餐的菜单'; + + @override + String get generatingMenu => '正在生成菜单...'; + + @override + String get weekPlannedLabel => '本周已规划'; } diff --git a/client/lib/l10n/app_pt.arb b/client/lib/l10n/app_pt.arb index be183ce..3a616fd 100644 --- a/client/lib/l10n/app_pt.arb +++ b/client/lib/l10n/app_pt.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "Marcar como comido", + "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" } diff --git a/client/lib/l10n/app_ru.arb b/client/lib/l10n/app_ru.arb index 46f78c3..4b5a056 100644 --- a/client/lib/l10n/app_ru.arb +++ b/client/lib/l10n/app_ru.arb @@ -129,5 +129,9 @@ } }, "markAsEaten": "Отметить как съеденное", - "plannedMealLabel": "Запланировано" + "plannedMealLabel": "Запланировано", + "generateWeekLabel": "Запланировать неделю", + "generateWeekSubtitle": "AI составит меню с завтраком, обедом и ужином на всю неделю", + "generatingMenu": "Генерируем меню...", + "weekPlannedLabel": "Неделя запланирована" } diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index 1543452..372e3b9 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -130,6 +130,10 @@ "date": { "type": "String" } } }, - "markAsEaten": "", - "plannedMealLabel": "" + "markAsEaten": "标记为已吃", + "plannedMealLabel": "已计划", + "generateWeekLabel": "规划本周", + "generateWeekSubtitle": "AI将为整周创建含早餐、午餐和晚餐的菜单", + "generatingMenu": "正在生成菜单...", + "weekPlannedLabel": "本周已规划" } diff --git a/client/pubspec.lock b/client/pubspec.lock index a2acdd5..223b5f2 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -689,26 +689,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1078,10 +1078,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: