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:
@@ -13,12 +13,14 @@ import (
|
||||
"github.com/food-ai/backend/internal/infra/config"
|
||||
"github.com/food-ai/backend/internal/infra/database"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/reqlog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
})
|
||||
logger := slog.New(reqlog.New(baseHandler))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if runError := run(); runError != nil {
|
||||
|
||||
@@ -30,18 +30,18 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req loginRequest
|
||||
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.FirebaseToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
|
||||
writeErrorJSON(w, r, http.StatusBadRequest, "firebase_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
|
||||
writeErrorJSON(w, r, http.StatusUnauthorized, "authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,18 +52,18 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req refreshRequest
|
||||
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.RefreshToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
|
||||
writeErrorJSON(w, r, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
|
||||
writeErrorJSON(w, r, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,12 +73,12 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) Logout(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
|
||||
}
|
||||
|
||||
if err := h.service.Logout(r.Context(), userID); err != nil {
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
|
||||
writeErrorJSON(w, r, http.StatusInternalServerError, "logout failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,14 +86,18 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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("failed to 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(), "failed to write error response", "err", encodeErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -26,8 +27,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $1
|
||||
ORDER BY c.sort_order`, lang)
|
||||
if queryError != nil {
|
||||
slog.Error("list cuisines", "err", queryError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
||||
slog.ErrorContext(request.Context(), "list cuisines", "err", queryError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var item cuisineItem
|
||||
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
||||
slog.Error("scan cuisine row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
||||
slog.ErrorContext(request.Context(), "scan cuisine row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if rowsError := rows.Err(); rowsError != nil {
|
||||
slog.Error("iterate cuisine rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
||||
slog.ErrorContext(request.Context(), "iterate cuisine rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,8 +54,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message 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(map[string]string{"error": message})
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(request.Context())})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for dishes.
|
||||
@@ -34,8 +35,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
results, searchError := h.repo.Search(r.Context(), query, limit)
|
||||
if searchError != nil {
|
||||
slog.Error("search dishes", "err", searchError)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search dishes")
|
||||
slog.ErrorContext(r.Context(), "search dishes", "err", searchError)
|
||||
writeError(w, r, http.StatusInternalServerError, "failed to search dishes")
|
||||
return
|
||||
}
|
||||
if results == nil {
|
||||
@@ -48,8 +49,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
dishes, err := h.repo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("list dishes", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to list dishes")
|
||||
slog.ErrorContext(r.Context(), "list dishes", "err", err)
|
||||
writeError(w, r, http.StatusInternalServerError, "failed to list dishes")
|
||||
return
|
||||
}
|
||||
if dishes == nil {
|
||||
@@ -63,12 +64,12 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
dish, err := h.repo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("get dish", "id", id, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to get dish")
|
||||
slog.ErrorContext(r.Context(), "get dish", "id", id, "err", err)
|
||||
writeError(w, r, http.StatusInternalServerError, "failed to get dish")
|
||||
return
|
||||
}
|
||||
if dish == nil {
|
||||
writeError(w, http.StatusNotFound, "dish not found")
|
||||
writeError(w, r, http.StatusNotFound, "dish not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dish)
|
||||
@@ -77,13 +78,17 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
// --- helpers ---
|
||||
|
||||
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) {
|
||||
|
||||
@@ -312,13 +312,20 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st
|
||||
return "", fmt.Errorf("insert dish: %w", err)
|
||||
}
|
||||
|
||||
// Insert tags.
|
||||
// Insert tags — upsert into tags first so the FK constraint is satisfied
|
||||
// even when the AI generates a tag slug that does not exist yet.
|
||||
for _, slug := range req.Tags {
|
||||
if _, err := tx.Exec(ctx,
|
||||
if _, upsertErr := tx.Exec(ctx,
|
||||
`INSERT INTO tags (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING`,
|
||||
slug, slug,
|
||||
); upsertErr != nil {
|
||||
return "", fmt.Errorf("upsert tag %s: %w", slug, upsertErr)
|
||||
}
|
||||
if _, insertErr := tx.Exec(ctx,
|
||||
`INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
dishID, slug,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("insert dish tag %s: %w", slug, err)
|
||||
); insertErr != nil {
|
||||
return "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -26,7 +27,7 @@ func NewHandler(pool *pgxpool.Pool) *Handler {
|
||||
func (h *Handler) GetSummary(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
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ func (h *Handler) getDailyGoal(ctx context.Context, userID string) int {
|
||||
userID,
|
||||
).Scan(&goal)
|
||||
if err != nil {
|
||||
slog.Warn("home: get daily goal", "user_id", userID, "err", err)
|
||||
slog.WarnContext(ctx, "home: get daily goal", "user_id", userID, "err", err)
|
||||
return 2000
|
||||
}
|
||||
return goal
|
||||
@@ -99,21 +100,24 @@ func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) fl
|
||||
// getTodayPlan returns the three meal slots planned for today.
|
||||
// If no menu exists, all three slots are returned with nil recipe fields.
|
||||
func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan {
|
||||
lang := locale.FromContext(ctx)
|
||||
const q = `
|
||||
SELECT mi.meal_type,
|
||||
sr.title,
|
||||
sr.image_url,
|
||||
(sr.nutrition->>'calories')::float
|
||||
COALESCE(dt.name, d.name) AS title,
|
||||
d.image_url,
|
||||
rec.calories_per_serving
|
||||
FROM menu_plans mp
|
||||
JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
||||
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
||||
LEFT JOIN recipes rec ON rec.id = mi.recipe_id
|
||||
LEFT JOIN dishes d ON d.id = rec.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $4
|
||||
WHERE mp.user_id = $1
|
||||
AND mp.week_start::text = $2
|
||||
AND mi.day_of_week = $3`
|
||||
|
||||
rows, err := h.pool.Query(ctx, q, userID, weekStart, dow)
|
||||
rows, err := h.pool.Query(ctx, q, userID, weekStart, dow, lang)
|
||||
if err != nil {
|
||||
slog.Warn("home: get today plan", "err", err)
|
||||
slog.WarnContext(ctx, "home: get today plan", "err", err)
|
||||
return defaultPlan()
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -175,7 +179,7 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("home: get expiring soon", "err", err)
|
||||
slog.WarnContext(ctx, "home: get expiring soon", "err", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -197,19 +201,25 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
|
||||
return result
|
||||
}
|
||||
|
||||
// getRecommendations returns the 3 most recently generated recipe recommendations.
|
||||
// getRecommendations returns the 3 most recently saved recommendation recipes.
|
||||
func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation {
|
||||
lang := locale.FromContext(ctx)
|
||||
rows, err := h.pool.Query(ctx, `
|
||||
SELECT id, title, COALESCE(image_url, ''),
|
||||
(nutrition->>'calories')::float
|
||||
FROM saved_recipes
|
||||
WHERE user_id = $1 AND source = 'recommendation'
|
||||
ORDER BY saved_at DESC
|
||||
SELECT rec.id,
|
||||
COALESCE(dt.name, d.name),
|
||||
COALESCE(d.image_url, ''),
|
||||
rec.calories_per_serving
|
||||
FROM user_saved_recipes usr
|
||||
JOIN recipes rec ON rec.id = usr.recipe_id
|
||||
JOIN dishes d ON d.id = rec.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||
WHERE usr.user_id = $1 AND rec.source = 'recommendation'
|
||||
ORDER BY usr.saved_at DESC
|
||||
LIMIT 3`,
|
||||
userID,
|
||||
userID, lang,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("home: get recommendations", "err", err)
|
||||
slog.WarnContext(ctx, "home: get recommendations", "err", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -238,10 +248,10 @@ func mondayOfISOWeek(year, week int) time.Time {
|
||||
return monday1.AddDate(0, 0, (week-1)*7)
|
||||
}
|
||||
|
||||
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(map[string]string{"error": msg})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(r.Context())})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
)
|
||||
|
||||
// ProductSearcher is the data layer interface used by Handler for search.
|
||||
@@ -51,7 +52,7 @@ func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http
|
||||
|
||||
products, searchError := handler.repo.Search(request.Context(), query, limit)
|
||||
if searchError != nil {
|
||||
slog.Error("search catalog products", "q", query, "err", searchError)
|
||||
slog.ErrorContext(request.Context(), "search catalog products", "q", query, "err", searchError)
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
responseWriter.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = responseWriter.Write([]byte(`{"error":"search failed"}`))
|
||||
@@ -71,15 +72,15 @@ func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http
|
||||
func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
barcode := chi.URLParam(request, "barcode")
|
||||
if barcode == "" {
|
||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "barcode is required")
|
||||
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "barcode is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Check the local catalog first.
|
||||
catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode)
|
||||
if lookupError != nil {
|
||||
slog.Error("lookup product by barcode", "barcode", barcode, "err", lookupError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "lookup failed")
|
||||
slog.ErrorContext(request.Context(), "lookup product by barcode", "barcode", barcode, "err", lookupError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
if catalogProduct != nil {
|
||||
@@ -90,15 +91,15 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request
|
||||
// Not in catalog — fetch from Open Food Facts.
|
||||
fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode)
|
||||
if fetchError != nil {
|
||||
slog.Warn("open food facts fetch failed", "barcode", barcode, "err", fetchError)
|
||||
writeErrorJSON(responseWriter, http.StatusNotFound, "product not found")
|
||||
slog.WarnContext(request.Context(), "open food facts fetch failed", "barcode", barcode, "err", fetchError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusNotFound, "product not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the fetched product so subsequent lookups are served from the DB.
|
||||
savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct)
|
||||
if upsertError != nil {
|
||||
slog.Warn("upsert product from open food facts", "barcode", barcode, "err", upsertError)
|
||||
slog.WarnContext(request.Context(), "upsert product from open food facts", "barcode", barcode, "err", upsertError)
|
||||
// Return the fetched data even if we could not cache it.
|
||||
writeJSON(responseWriter, http.StatusOK, fetchedProduct)
|
||||
return
|
||||
@@ -107,13 +108,17 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -23,32 +23,36 @@ func NewHandler(repo *Repository) *Handler {
|
||||
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(), id)
|
||||
if err != nil {
|
||||
slog.Error("get recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe")
|
||||
slog.ErrorContext(r.Context(), "get recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get 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)
|
||||
}
|
||||
|
||||
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)
|
||||
_ = 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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoade
|
||||
func (h *Handler) GetRecommendations(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
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("load user for recommendations", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile")
|
||||
slog.ErrorContext(r.Context(), "load user for recommendations", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to load user profile")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,13 +86,13 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||
req.AvailableProducts = products
|
||||
} else {
|
||||
slog.Warn("load products for recommendations", "user_id", userID, "err", err)
|
||||
slog.WarnContext(r.Context(), "load products for recommendations", "user_id", userID, "err", err)
|
||||
}
|
||||
|
||||
recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req)
|
||||
if err != nil {
|
||||
slog.Error("generate recipes", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again")
|
||||
slog.ErrorContext(r.Context(), "generate recipes", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, r, http.StatusServiceUnavailable, "recipe generation failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
defer wg.Done()
|
||||
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
|
||||
if err != nil {
|
||||
slog.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
|
||||
slog.WarnContext(r.Context(), "pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
|
||||
}
|
||||
recipes[i].ImageURL = imageURL
|
||||
}(i)
|
||||
@@ -139,14 +139,18 @@ func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -26,8 +27,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug AND tt.lang = $1
|
||||
ORDER BY t.sort_order`, lang)
|
||||
if queryError != nil {
|
||||
slog.Error("list tags", "err", queryError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
||||
slog.ErrorContext(request.Context(), "list tags", "err", queryError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var item tagItem
|
||||
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
||||
slog.Error("scan tag row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
||||
slog.ErrorContext(request.Context(), "scan tag row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if rowsError := rows.Err(); rowsError != nil {
|
||||
slog.Error("iterate tag rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
||||
slog.ErrorContext(request.Context(), "iterate tag rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,8 +54,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message 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(map[string]string{"error": message})
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(request.Context())})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -26,8 +27,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
LEFT JOIN unit_translations ut ON ut.unit_code = u.code AND ut.lang = $1
|
||||
ORDER BY u.sort_order`, lang)
|
||||
if queryError != nil {
|
||||
slog.Error("list units", "err", queryError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
||||
slog.ErrorContext(request.Context(), "list units", "err", queryError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var item unitItem
|
||||
if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil {
|
||||
slog.Error("scan unit row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
||||
slog.ErrorContext(request.Context(), "scan unit row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if rowsError := rows.Err(); rowsError != nil {
|
||||
slog.Error("iterate unit rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
||||
slog.ErrorContext(request.Context(), "iterate unit rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,8 +54,8 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message 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(map[string]string{"error": message})
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"error": msg, "request_id": middleware.RequestIDFromCtx(request.Context())})
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ func NewHandler(service *Service) *Handler {
|
||||
func (h *Handler) Get(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
|
||||
}
|
||||
|
||||
u, err := h.service.GetProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusNotFound, "user not found")
|
||||
writeErrorJSON(w, r, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,20 +37,20 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) Update(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, maxRequestBodySize)
|
||||
var req UpdateProfileRequest
|
||||
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
|
||||
}
|
||||
|
||||
u, err := h.service.UpdateProfile(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, err.Error())
|
||||
writeErrorJSON(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,14 +58,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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("failed to 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(), "failed to write error response", "err", encodeErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.R
|
||||
userID := middleware.UserIDFromCtx(request.Context())
|
||||
userProducts, listError := handler.repo.List(request.Context(), userID)
|
||||
if listError != nil {
|
||||
slog.Error("list user products", "user_id", userID, "err", listError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list user products")
|
||||
slog.ErrorContext(request.Context(), "list user products", "user_id", userID, "err", listError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list user products")
|
||||
return
|
||||
}
|
||||
if userProducts == nil {
|
||||
@@ -50,18 +50,18 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http
|
||||
userID := middleware.UserIDFromCtx(request.Context())
|
||||
var req CreateRequest
|
||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
|
||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body")
|
||||
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "name is required")
|
||||
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
userProduct, createError := handler.repo.Create(request.Context(), userID, req)
|
||||
if createError != nil {
|
||||
slog.Error("create user product", "user_id", userID, "err", createError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user product")
|
||||
slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product")
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusCreated, userProduct)
|
||||
@@ -72,7 +72,7 @@ func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request
|
||||
userID := middleware.UserIDFromCtx(request.Context())
|
||||
var items []CreateRequest
|
||||
if decodeError := json.NewDecoder(request.Body).Decode(&items); decodeError != nil {
|
||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body")
|
||||
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
@@ -82,8 +82,8 @@ func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request
|
||||
|
||||
userProducts, batchError := handler.repo.BatchCreate(request.Context(), userID, items)
|
||||
if batchError != nil {
|
||||
slog.Error("batch create user products", "user_id", userID, "err", batchError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user products")
|
||||
slog.ErrorContext(request.Context(), "batch create user products", "user_id", userID, "err", batchError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user products")
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusCreated, userProducts)
|
||||
@@ -96,18 +96,18 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http
|
||||
|
||||
var req UpdateRequest
|
||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
|
||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body")
|
||||
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req)
|
||||
if errors.Is(updateError, ErrNotFound) {
|
||||
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found")
|
||||
writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found")
|
||||
return
|
||||
}
|
||||
if updateError != nil {
|
||||
slog.Error("update user product", "id", id, "err", updateError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to update user product")
|
||||
slog.ErrorContext(request.Context(), "update user product", "id", id, "err", updateError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to update user product")
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, userProduct)
|
||||
@@ -120,24 +120,28 @@ func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http
|
||||
|
||||
if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil {
|
||||
if errors.Is(deleteError, ErrNotFound) {
|
||||
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found")
|
||||
writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete user product", "id", id, "err", deleteError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to delete user product")
|
||||
slog.ErrorContext(request.Context(), "delete user product", "id", id, "err", deleteError)
|
||||
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete user product")
|
||||
return
|
||||
}
|
||||
responseWriter.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
42
backend/internal/infra/reqlog/handler.go
Normal file
42
backend/internal/infra/reqlog/handler.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package reqlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
)
|
||||
|
||||
// Handler is a slog.Handler wrapper that enriches ERROR-level records with
|
||||
// the request_id from context and a goroutine stack trace.
|
||||
type Handler struct {
|
||||
inner slog.Handler
|
||||
}
|
||||
|
||||
// New wraps inner with request-aware enrichment.
|
||||
func New(inner slog.Handler) *Handler {
|
||||
return &Handler{inner: inner}
|
||||
}
|
||||
|
||||
func (handler *Handler) Handle(ctx context.Context, record slog.Record) error {
|
||||
if record.Level >= slog.LevelError {
|
||||
if requestID := middleware.RequestIDFromCtx(ctx); requestID != "" {
|
||||
record.AddAttrs(slog.String("request_id", requestID))
|
||||
}
|
||||
record.AddAttrs(slog.String("stack", string(debug.Stack())))
|
||||
}
|
||||
return handler.inner.Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (handler *Handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return handler.inner.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (handler *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &Handler{inner: handler.inner.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (handler *Handler) WithGroup(name string) slog.Handler {
|
||||
return &Handler{inner: handler.inner.WithGroup(name)}
|
||||
}
|
||||
5
backend/migrations/003_add_menu_recipe_source.sql
Normal file
5
backend/migrations/003_add_menu_recipe_source.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TYPE recipe_source ADD VALUE IF NOT EXISTS 'menu';
|
||||
|
||||
-- +goose Down
|
||||
-- Cannot remove enum values in PostgreSQL
|
||||
27
backend/migrations/004_rename_recipe_products.sql
Normal file
27
backend/migrations/004_rename_recipe_products.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE recipe_products RENAME TO recipe_ingredients;
|
||||
ALTER INDEX idx_recipe_products_recipe_id RENAME TO idx_recipe_ingredients_recipe_id;
|
||||
|
||||
ALTER TABLE recipe_ingredients
|
||||
DROP CONSTRAINT recipe_products_product_id_fkey;
|
||||
ALTER TABLE recipe_ingredients
|
||||
RENAME COLUMN product_id TO ingredient_id;
|
||||
ALTER TABLE recipe_ingredients
|
||||
ADD CONSTRAINT recipe_ingredients_ingredient_id_fkey
|
||||
FOREIGN KEY (ingredient_id) REFERENCES products(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE recipe_product_translations RENAME TO recipe_ingredient_translations;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE recipe_ingredient_translations RENAME TO recipe_product_translations;
|
||||
|
||||
ALTER TABLE recipe_ingredients
|
||||
DROP CONSTRAINT recipe_ingredients_ingredient_id_fkey;
|
||||
ALTER TABLE recipe_ingredients
|
||||
RENAME COLUMN ingredient_id TO product_id;
|
||||
ALTER TABLE recipe_ingredients
|
||||
ADD CONSTRAINT recipe_products_product_id_fkey
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER INDEX idx_recipe_ingredients_recipe_id RENAME TO idx_recipe_products_recipe_id;
|
||||
ALTER TABLE recipe_ingredients RENAME TO recipe_products;
|
||||
Reference in New Issue
Block a user