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

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