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

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