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

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