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

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

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

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

1
.gitignore vendored
View File

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

View File

@@ -13,12 +13,14 @@ import (
"github.com/food-ai/backend/internal/infra/config"
"github.com/food-ai/backend/internal/infra/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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ class HomeScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => 'تم تخطيط الأسبوع';
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => 'सप्ताह की योजना बनाई गई';
}

View File

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

View File

@@ -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 => '週の計画済み';
}

View File

@@ -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 => '주간 계획 완료';
}

View File

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

View File

@@ -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 => 'Неделя запланирована';
}

View File

@@ -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 => '本周已规划';
}

View File

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

View File

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

View File

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

View File

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