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

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