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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user