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

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