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:
@@ -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) {
|
||||
|
||||
@@ -147,17 +147,17 @@ func (broker *SSEBroker) ServeSSE(responseWriter http.ResponseWriter, request *h
|
||||
|
||||
job, fetchError := broker.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
|
||||
}
|
||||
|
||||
flusher, supported := responseWriter.(http.Flusher)
|
||||
if !supported {
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "streaming not supported")
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user