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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ client/ios/Runner/GoogleService-Info.plist
|
|||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
backend/.env
|
backend/.env
|
||||||
|
backend/server
|
||||||
backend/importoff
|
backend/importoff
|
||||||
backend/openfoodfacts-products.jsonl
|
backend/openfoodfacts-products.jsonl
|
||||||
backend/openfoodfacts-products.jsonl.gz
|
backend/openfoodfacts-products.jsonl.gz
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/infra/config"
|
"github.com/food-ai/backend/internal/infra/config"
|
||||||
"github.com/food-ai/backend/internal/infra/database"
|
"github.com/food-ai/backend/internal/infra/database"
|
||||||
"github.com/food-ai/backend/internal/infra/locale"
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
|
"github.com/food-ai/backend/internal/infra/reqlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
Level: slog.LevelInfo,
|
Level: slog.LevelInfo,
|
||||||
}))
|
})
|
||||||
|
logger := slog.New(reqlog.New(baseHandler))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
if runError := run(); runError != nil {
|
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)
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.FirebaseToken == "" {
|
if req.FirebaseToken == "" {
|
||||||
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
|
writeErrorJSON(w, r, http.StatusBadRequest, "firebase_token is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
|
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "authentication failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,18 +52,18 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||||
var req refreshRequest
|
var req refreshRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.RefreshToken == "" {
|
if req.RefreshToken == "" {
|
||||||
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
|
writeErrorJSON(w, r, http.StatusBadRequest, "refresh_token is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "invalid refresh token")
|
||||||
return
|
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) {
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.Logout(r.Context(), userID); err != nil {
|
if err := h.service.Logout(r.Context(), userID); err != nil {
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "logout failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +87,17 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
if encodeErr := json.NewEncoder(w).Encode(errorResponse{
|
||||||
slog.Error("failed to write error response", "err", err)
|
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"
|
"net/http"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/infra/locale"
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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
|
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $1
|
||||||
ORDER BY c.sort_order`, lang)
|
ORDER BY c.sort_order`, lang)
|
||||||
if queryError != nil {
|
if queryError != nil {
|
||||||
slog.Error("list cuisines", "err", queryError)
|
slog.ErrorContext(request.Context(), "list cuisines", "err", queryError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item cuisineItem
|
var item cuisineItem
|
||||||
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
||||||
slog.Error("scan cuisine row", "err", scanError)
|
slog.ErrorContext(request.Context(), "scan cuisine row", "err", scanError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
if rowsError := rows.Err(); rowsError != nil {
|
if rowsError := rows.Err(); rowsError != nil {
|
||||||
slog.Error("iterate cuisine rows", "err", rowsError)
|
slog.ErrorContext(request.Context(), "iterate cuisine rows", "err", rowsError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load cuisines")
|
||||||
return
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(status)
|
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) {
|
func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
date := r.URL.Query().Get("date")
|
date := r.URL.Query().Get("date")
|
||||||
if 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, listError := h.repo.ListByDate(r.Context(), userID, date)
|
entries, listError := h.repo.ListByDate(r.Context(), userID, date)
|
||||||
if listError != nil {
|
if listError != nil {
|
||||||
slog.Error("list diary by date", "err", listError)
|
slog.ErrorContext(r.Context(), "list diary by date", "err", listError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load diary")
|
writeError(w, r, http.StatusInternalServerError, "failed to load diary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if entries == nil {
|
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) {
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req CreateRequest
|
var req CreateRequest
|
||||||
if decodeError := json.NewDecoder(r.Body).Decode(&req); decodeError != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.Date == "" || req.MealType == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.DishID == nil && req.ProductID == nil && req.Name == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.DishID == nil {
|
if req.DishID == nil {
|
||||||
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
|
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
|
||||||
if resolveError != nil {
|
if resolveError != nil {
|
||||||
slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError)
|
slog.ErrorContext(r.Context(), "resolve dish for diary entry", "name", req.Name, "err", resolveError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to resolve dish")
|
writeError(w, r, http.StatusInternalServerError, "failed to resolve dish")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.DishID = &dishID
|
req.DishID = &dishID
|
||||||
@@ -104,8 +104,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.RecipeID == nil {
|
if req.RecipeID == nil {
|
||||||
recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0)
|
recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0)
|
||||||
if recipeError != nil {
|
if recipeError != nil {
|
||||||
slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError)
|
slog.ErrorContext(r.Context(), "find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to resolve recipe")
|
writeError(w, r, http.StatusInternalServerError, "failed to resolve recipe")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.RecipeID = &recipeID
|
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)
|
entry, createError := h.repo.Create(r.Context(), userID, req)
|
||||||
if createError != nil {
|
if createError != nil {
|
||||||
slog.Error("create diary entry", "err", createError)
|
slog.ErrorContext(r.Context(), "create diary entry", "err", createError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create diary entry")
|
writeError(w, r, http.StatusInternalServerError, "failed to create diary entry")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, entry)
|
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) {
|
func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit := 10
|
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)
|
items, queryError := h.repo.GetRecent(r.Context(), userID, limit)
|
||||||
if queryError != nil {
|
if queryError != nil {
|
||||||
slog.Error("get recent diary items", "err", queryError)
|
slog.ErrorContext(r.Context(), "get recent diary items", "err", queryError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to get recent items")
|
writeError(w, r, http.StatusInternalServerError, "failed to get recent items")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if items == nil {
|
if items == nil {
|
||||||
@@ -150,18 +150,18 @@ func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil {
|
if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil {
|
||||||
if deleteError == ErrNotFound {
|
if deleteError == ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "diary entry not found")
|
writeError(w, r, http.StatusNotFound, "diary entry not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("delete diary entry", "err", deleteError)
|
slog.ErrorContext(r.Context(), "delete diary entry", "err", deleteError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
|
writeError(w, r, http.StatusInternalServerError, "failed to delete diary entry")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -169,12 +169,16 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles HTTP requests for dishes.
|
// 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)
|
results, searchError := h.repo.Search(r.Context(), query, limit)
|
||||||
if searchError != nil {
|
if searchError != nil {
|
||||||
slog.Error("search dishes", "err", searchError)
|
slog.ErrorContext(r.Context(), "search dishes", "err", searchError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to search dishes")
|
writeError(w, r, http.StatusInternalServerError, "failed to search dishes")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if results == nil {
|
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) {
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
dishes, err := h.repo.List(r.Context())
|
dishes, err := h.repo.List(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("list dishes", "err", err)
|
slog.ErrorContext(r.Context(), "list dishes", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list dishes")
|
writeError(w, r, http.StatusInternalServerError, "failed to list dishes")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if dishes == nil {
|
if dishes == nil {
|
||||||
@@ -63,12 +64,12 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
dish, err := h.repo.GetByID(r.Context(), id)
|
dish, err := h.repo.GetByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("get dish", "id", id, "err", err)
|
slog.ErrorContext(r.Context(), "get dish", "id", id, "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to get dish")
|
writeError(w, r, http.StatusInternalServerError, "failed to get dish")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if dish == nil {
|
if dish == nil {
|
||||||
writeError(w, http.StatusNotFound, "dish not found")
|
writeError(w, r, http.StatusNotFound, "dish not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, dish)
|
writeJSON(w, http.StatusOK, dish)
|
||||||
@@ -78,12 +79,16 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
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)
|
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 {
|
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`,
|
`INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
dishID, slug,
|
dishID, slug,
|
||||||
); err != nil {
|
); insertErr != nil {
|
||||||
return "", fmt.Errorf("insert dish tag %s: %w", slug, err)
|
return "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/infra/middleware"
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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) {
|
func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ func (h *Handler) getDailyGoal(ctx context.Context, userID string) int {
|
|||||||
userID,
|
userID,
|
||||||
).Scan(&goal)
|
).Scan(&goal)
|
||||||
if err != nil {
|
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 2000
|
||||||
}
|
}
|
||||||
return goal
|
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.
|
// getTodayPlan returns the three meal slots planned for today.
|
||||||
// If no menu exists, all three slots are returned with nil recipe fields.
|
// 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 {
|
func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
const q = `
|
const q = `
|
||||||
SELECT mi.meal_type,
|
SELECT mi.meal_type,
|
||||||
sr.title,
|
COALESCE(dt.name, d.name) AS title,
|
||||||
sr.image_url,
|
d.image_url,
|
||||||
(sr.nutrition->>'calories')::float
|
rec.calories_per_serving
|
||||||
FROM menu_plans mp
|
FROM menu_plans mp
|
||||||
JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
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
|
WHERE mp.user_id = $1
|
||||||
AND mp.week_start::text = $2
|
AND mp.week_start::text = $2
|
||||||
AND mi.day_of_week = $3`
|
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 {
|
if err != nil {
|
||||||
slog.Warn("home: get today plan", "err", err)
|
slog.WarnContext(ctx, "home: get today plan", "err", err)
|
||||||
return defaultPlan()
|
return defaultPlan()
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -175,7 +179,7 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
|
|||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("home: get expiring soon", "err", err)
|
slog.WarnContext(ctx, "home: get expiring soon", "err", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -197,19 +201,25 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
|
|||||||
return result
|
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 {
|
func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
rows, err := h.pool.Query(ctx, `
|
rows, err := h.pool.Query(ctx, `
|
||||||
SELECT id, title, COALESCE(image_url, ''),
|
SELECT rec.id,
|
||||||
(nutrition->>'calories')::float
|
COALESCE(dt.name, d.name),
|
||||||
FROM saved_recipes
|
COALESCE(d.image_url, ''),
|
||||||
WHERE user_id = $1 AND source = 'recommendation'
|
rec.calories_per_serving
|
||||||
ORDER BY saved_at DESC
|
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`,
|
LIMIT 3`,
|
||||||
userID,
|
userID, lang,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("home: get recommendations", "err", err)
|
slog.WarnContext(ctx, "home: get recommendations", "err", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -238,10 +248,10 @@ func mondayOfISOWeek(year, week int) time.Time {
|
|||||||
return monday1.AddDate(0, 0, (week-1)*7)
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
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) {
|
func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
|
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("get menu", "err", err)
|
slog.ErrorContext(r.Context(), "get menu", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load menu")
|
writeError(w, r, http.StatusInternalServerError, "failed to load menu")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if plan == nil {
|
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) {
|
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,15 +122,15 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
weekStart, err := ResolveWeekStart(body.Week)
|
weekStart, err := ResolveWeekStart(body.Week)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user profile.
|
// Load user profile.
|
||||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("load user for menu generation", "err", err)
|
slog.ErrorContext(r.Context(), "load user for menu generation", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load user profile")
|
writeError(w, r, http.StatusInternalServerError, "failed to load user profile")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Generate 7-day plan via Gemini.
|
// Generate 7-day plan via Gemini.
|
||||||
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
|
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("generate menu", "user_id", userID, "err", err)
|
slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", err)
|
||||||
writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again")
|
writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
url, err := h.pexels.SearchPhoto(r.Context(), query)
|
url, err := h.pexels.SearchPhoto(r.Context(), query)
|
||||||
if err != nil {
|
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()
|
mu.Lock()
|
||||||
imageResults = append(imageResults, indexedRecipe{di, mi, url})
|
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 {
|
for mi, meal := range day.Meals {
|
||||||
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
|
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
slog.ErrorContext(r.Context(), "save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to save recipes")
|
writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refs = append(refs, savedRef{di, mi, recipeID})
|
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.
|
// Persist in a single transaction.
|
||||||
planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
|
planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("save menu plan", "err", err)
|
slog.ErrorContext(r.Context(), "save menu plan", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to save menu plan")
|
writeError(w, r, http.StatusInternalServerError, "failed to save menu plan")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-generate shopping list.
|
// Auto-generate shopping list.
|
||||||
if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil {
|
if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil {
|
||||||
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); 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.
|
// Return the freshly saved plan.
|
||||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||||
if err != nil || plan == nil {
|
if err != nil || plan == nil {
|
||||||
slog.Error("load generated menu", "err", err, "plan_nil", plan == nil)
|
slog.ErrorContext(r.Context(), "load generated menu", "err", err, "plan_nil", plan == nil)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load generated menu")
|
writeError(w, r, http.StatusInternalServerError, "failed to load generated menu")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, plan)
|
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) {
|
func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,17 +248,17 @@ func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
RecipeID string `json:"recipe_id"`
|
RecipeID string `json:"recipe_id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
|
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
|
||||||
if err == ErrNotFound {
|
if err == ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "menu item not found")
|
writeError(w, r, http.StatusNotFound, "menu item not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("update menu item", "err", err)
|
slog.ErrorContext(r.Context(), "update menu item", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update menu item")
|
writeError(w, r, http.StatusInternalServerError, "failed to update menu item")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
itemID := chi.URLParam(r, "id")
|
itemID := chi.URLParam(r, "id")
|
||||||
if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil {
|
if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil {
|
||||||
if err == ErrNotFound {
|
if err == ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "menu item not found")
|
writeError(w, r, http.StatusNotFound, "menu item not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("delete menu item", "err", err)
|
slog.ErrorContext(r.Context(), "delete menu item", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete menu item")
|
writeError(w, r, http.StatusInternalServerError, "failed to delete menu item")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,31 +304,31 @@ func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
weekStart, err := ResolveWeekStart(body.Week)
|
weekStart, err := ResolveWeekStart(body.Week)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == ErrNotFound {
|
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
|
return
|
||||||
}
|
}
|
||||||
slog.Error("get plan id", "err", err)
|
slog.ErrorContext(r.Context(), "get plan id", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
|
writeError(w, r, http.StatusInternalServerError, "failed to find menu plan")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := h.buildShoppingList(r.Context(), planID)
|
items, err := h.buildShoppingList(r.Context(), planID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("build shopping list", "err", err)
|
slog.ErrorContext(r.Context(), "build shopping list", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to build shopping list")
|
writeError(w, r, http.StatusInternalServerError, "failed to build shopping list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
|
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
|
||||||
slog.Error("upsert shopping list", "err", err)
|
slog.ErrorContext(r.Context(), "upsert shopping list", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to save shopping list")
|
writeError(w, r, http.StatusInternalServerError, "failed to save shopping list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, items)
|
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) {
|
func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
|
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,13 +354,13 @@ func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, []ShoppingItem{})
|
writeJSON(w, http.StatusOK, []ShoppingItem{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
|
writeError(w, r, http.StatusInternalServerError, "failed to find menu plan")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
|
items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load shopping list")
|
writeError(w, r, http.StatusInternalServerError, "failed to load shopping list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if items == nil {
|
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) {
|
func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
indexStr := chi.URLParam(r, "index")
|
indexStr := chi.URLParam(r, "index")
|
||||||
var index int
|
var index int
|
||||||
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,25 +388,25 @@ func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
Checked bool `json:"checked"`
|
Checked bool `json:"checked"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
|
weekStart, err := ResolveWeekStart(r.URL.Query().Get("week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "menu plan not found")
|
writeError(w, r, http.StatusNotFound, "menu plan not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
|
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
|
||||||
slog.Error("toggle shopping item", "err", err)
|
slog.ErrorContext(r.Context(), "toggle shopping item", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update item")
|
writeError(w, r, http.StatusInternalServerError, "failed to update item")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -569,12 +569,16 @@ func mondayOfISOWeek(year, week int) time.Time {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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.
|
// 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)
|
products, searchError := handler.repo.Search(request.Context(), query, limit)
|
||||||
if searchError != nil {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(http.StatusInternalServerError)
|
responseWriter.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = responseWriter.Write([]byte(`{"error":"search failed"}`))
|
_, _ = 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) {
|
func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
barcode := chi.URLParam(request, "barcode")
|
barcode := chi.URLParam(request, "barcode")
|
||||||
if barcode == "" {
|
if barcode == "" {
|
||||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "barcode is required")
|
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "barcode is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the local catalog first.
|
// Check the local catalog first.
|
||||||
catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode)
|
catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode)
|
||||||
if lookupError != nil {
|
if lookupError != nil {
|
||||||
slog.Error("lookup product by barcode", "barcode", barcode, "err", lookupError)
|
slog.ErrorContext(request.Context(), "lookup product by barcode", "barcode", barcode, "err", lookupError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "lookup failed")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "lookup failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if catalogProduct != nil {
|
if catalogProduct != nil {
|
||||||
@@ -90,15 +91,15 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request
|
|||||||
// Not in catalog — fetch from Open Food Facts.
|
// Not in catalog — fetch from Open Food Facts.
|
||||||
fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode)
|
fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode)
|
||||||
if fetchError != nil {
|
if fetchError != nil {
|
||||||
slog.Warn("open food facts fetch failed", "barcode", barcode, "err", fetchError)
|
slog.WarnContext(request.Context(), "open food facts fetch failed", "barcode", barcode, "err", fetchError)
|
||||||
writeErrorJSON(responseWriter, http.StatusNotFound, "product not found")
|
writeErrorJSON(responseWriter, request, http.StatusNotFound, "product not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the fetched product so subsequent lookups are served from the DB.
|
// Persist the fetched product so subsequent lookups are served from the DB.
|
||||||
savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct)
|
savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct)
|
||||||
if upsertError != nil {
|
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.
|
// Return the fetched data even if we could not cache it.
|
||||||
writeJSON(responseWriter, http.StatusOK, fetchedProduct)
|
writeJSON(responseWriter, http.StatusOK, fetchedProduct)
|
||||||
return
|
return
|
||||||
@@ -108,12 +109,16 @@ func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(status)
|
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) {
|
func writeJSON(responseWriter http.ResponseWriter, status int, value any) {
|
||||||
|
|||||||
@@ -23,19 +23,19 @@ func NewHandler(repo *Repository) *Handler {
|
|||||||
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
rec, err := h.repo.GetByID(r.Context(), id)
|
rec, err := h.repo.GetByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("get recipe", "id", id, "err", err)
|
slog.ErrorContext(r.Context(), "get recipe", "id", id, "err", err)
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get recipe")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rec == nil {
|
if rec == nil {
|
||||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
writeErrorJSON(w, r, http.StatusNotFound, "recipe not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, rec)
|
writeJSON(w, http.StatusOK, rec)
|
||||||
@@ -43,12 +43,16 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
|||||||
@@ -125,15 +125,15 @@ func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, req
|
|||||||
|
|
||||||
var req imageRequest
|
var req imageRequest
|
||||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lang := locale.FromContext(request.Context())
|
lang := locale.FromContext(request.Context())
|
||||||
result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang)
|
result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang)
|
||||||
if recognizeError != nil {
|
if recognizeError != nil {
|
||||||
slog.Error("recognize receipt", "err", recognizeError)
|
slog.ErrorContext(request.Context(), "recognize receipt", "err", recognizeError)
|
||||||
writeErrorJSON(responseWriter, http.StatusServiceUnavailable, "recognition failed, please try again")
|
writeErrorJSON(responseWriter, request, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, req
|
|||||||
func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) {
|
func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
var req imagesRequest
|
var req imagesRequest
|
||||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || len(req.Images) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
if len(req.Images) > 3 {
|
if len(req.Images) > 3 {
|
||||||
@@ -165,7 +165,7 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang)
|
items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang)
|
||||||
if recognizeError != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
allItems[index] = items
|
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) {
|
func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
var req recognizeDishRequest
|
var req recognizeDishRequest
|
||||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +202,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
|
|||||||
TargetMealType: req.TargetMealType,
|
TargetMealType: req.TargetMealType,
|
||||||
}
|
}
|
||||||
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
|
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
|
||||||
slog.Error("insert recognition job", "err", insertError)
|
slog.ErrorContext(request.Context(), "insert recognition job", "err", insertError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create job")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create job")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,8 +217,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
|
|||||||
topic = TopicPaid
|
topic = TopicPaid
|
||||||
}
|
}
|
||||||
if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil {
|
if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil {
|
||||||
slog.Error("publish recognition job", "job_id", job.ID, "err", publishError)
|
slog.ErrorContext(request.Context(), "publish recognition job", "job_id", job.ID, "err", publishError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to enqueue job")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to enqueue job")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +236,8 @@ func (handler *Handler) ListTodayJobs(responseWriter http.ResponseWriter, reques
|
|||||||
|
|
||||||
summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID)
|
summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID)
|
||||||
if listError != nil {
|
if listError != nil {
|
||||||
slog.Error("list today unlinked jobs", "err", listError)
|
slog.ErrorContext(request.Context(), "list today unlinked jobs", "err", listError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,8 +254,8 @@ func (handler *Handler) ListAllJobs(responseWriter http.ResponseWriter, request
|
|||||||
|
|
||||||
summaries, listError := handler.jobRepo.ListAll(request.Context(), userID)
|
summaries, listError := handler.jobRepo.ListAll(request.Context(), userID)
|
||||||
if listError != nil {
|
if listError != nil {
|
||||||
slog.Error("list all jobs", "err", listError)
|
slog.ErrorContext(request.Context(), "list all jobs", "err", listError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,11 +277,11 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http
|
|||||||
|
|
||||||
job, fetchError := handler.jobRepo.GetJobByID(request.Context(), jobID)
|
job, fetchError := handler.jobRepo.GetJobByID(request.Context(), jobID)
|
||||||
if fetchError != nil {
|
if fetchError != nil {
|
||||||
writeErrorJSON(responseWriter, http.StatusNotFound, "job not found")
|
writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if job.UserID != userID {
|
if job.UserID != userID {
|
||||||
writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden")
|
writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(responseWriter, http.StatusOK, job)
|
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)
|
catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name)
|
||||||
if matchError != nil {
|
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 {
|
if catalogProduct != nil {
|
||||||
@@ -325,7 +325,7 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt
|
|||||||
} else {
|
} else {
|
||||||
classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name)
|
classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name)
|
||||||
if classifyError != nil {
|
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 {
|
} else {
|
||||||
saved := handler.saveClassification(ctx, classification)
|
saved := handler.saveClassification(ctx, classification)
|
||||||
if saved != nil {
|
if saved != nil {
|
||||||
@@ -361,7 +361,7 @@ func (handler *Handler) saveClassification(ctx context.Context, classification *
|
|||||||
|
|
||||||
saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct)
|
saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct)
|
||||||
if upsertError != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +418,16 @@ func intPtr(n int) *int {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(status)
|
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) {
|
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)
|
job, fetchError := broker.jobRepo.GetJobByID(request.Context(), jobID)
|
||||||
if fetchError != nil {
|
if fetchError != nil {
|
||||||
writeErrorJSON(responseWriter, http.StatusNotFound, "job not found")
|
writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if job.UserID != userID {
|
if job.UserID != userID {
|
||||||
writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden")
|
writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
flusher, supported := responseWriter.(http.Flusher)
|
flusher, supported := responseWriter.(http.Flusher)
|
||||||
if !supported {
|
if !supported {
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "streaming not supported")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "streaming not supported")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoade
|
|||||||
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("load user for recommendations", "user_id", userID, "err", err)
|
slog.ErrorContext(r.Context(), "load user for recommendations", "user_id", userID, "err", err)
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to load user profile")
|
||||||
return
|
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 {
|
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||||
req.AvailableProducts = products
|
req.AvailableProducts = products
|
||||||
} else {
|
} 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)
|
recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("generate recipes", "user_id", userID, "err", err)
|
slog.ErrorContext(r.Context(), "generate recipes", "user_id", userID, "err", err)
|
||||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again")
|
writeErrorJSON(w, r, http.StatusServiceUnavailable, "recipe generation failed, please try again")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
|
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
|
||||||
if err != nil {
|
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
|
recipes[i].ImageURL = imageURL
|
||||||
}(i)
|
}(i)
|
||||||
@@ -140,13 +140,17 @@ func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
if encodeErr := json.NewEncoder(w).Encode(errorResponse{
|
||||||
slog.Error("write error response", "err", err)
|
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) {
|
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||||
var req SaveRequest
|
var req SaveRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.Title == "" && req.RecipeID == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := h.repo.Save(r.Context(), userID, req)
|
rec, err := h.repo.Save(r.Context(), userID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("save recipe", "err", err)
|
slog.ErrorContext(r.Context(), "save recipe", "err", err)
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to save recipe")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, rec)
|
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) {
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
recipes, err := h.repo.List(r.Context(), userID)
|
recipes, err := h.repo.List(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("list saved recipes", "err", err)
|
slog.ErrorContext(r.Context(), "list saved recipes", "err", err)
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to list saved recipes")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if recipes == nil {
|
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) {
|
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
rec, err := h.repo.GetByID(r.Context(), userID, id)
|
rec, err := h.repo.GetByID(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("get saved recipe", "id", id, "err", err)
|
slog.ErrorContext(r.Context(), "get saved recipe", "id", id, "err", err)
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to get saved recipe")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rec == nil {
|
if rec == nil {
|
||||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
writeErrorJSON(w, r, http.StatusNotFound, "recipe not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, rec)
|
writeJSON(w, http.StatusOK, rec)
|
||||||
@@ -105,18 +105,18 @@ func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if err := h.repo.Delete(r.Context(), userID, id); err != nil {
|
if err := h.repo.Delete(r.Context(), userID, id); err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
writeErrorJSON(w, r, http.StatusNotFound, "recipe not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("delete saved recipe", "id", id, "err", err)
|
slog.ErrorContext(r.Context(), "delete saved recipe", "id", id, "err", err)
|
||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe")
|
writeErrorJSON(w, r, http.StatusInternalServerError, "failed to delete recipe")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -124,13 +124,17 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
if encodeErr := json.NewEncoder(w).Encode(errorResponse{
|
||||||
slog.Error("write error response", "err", err)
|
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"
|
"net/http"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/infra/locale"
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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
|
LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug AND tt.lang = $1
|
||||||
ORDER BY t.sort_order`, lang)
|
ORDER BY t.sort_order`, lang)
|
||||||
if queryError != nil {
|
if queryError != nil {
|
||||||
slog.Error("list tags", "err", queryError)
|
slog.ErrorContext(request.Context(), "list tags", "err", queryError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item tagItem
|
var item tagItem
|
||||||
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
||||||
slog.Error("scan tag row", "err", scanError)
|
slog.ErrorContext(request.Context(), "scan tag row", "err", scanError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
if rowsError := rows.Err(); rowsError != nil {
|
if rowsError := rows.Err(); rowsError != nil {
|
||||||
slog.Error("iterate tag rows", "err", rowsError)
|
slog.ErrorContext(request.Context(), "iterate tag rows", "err", rowsError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load tags")
|
||||||
return
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(status)
|
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"
|
"net/http"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/infra/locale"
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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
|
LEFT JOIN unit_translations ut ON ut.unit_code = u.code AND ut.lang = $1
|
||||||
ORDER BY u.sort_order`, lang)
|
ORDER BY u.sort_order`, lang)
|
||||||
if queryError != nil {
|
if queryError != nil {
|
||||||
slog.Error("list units", "err", queryError)
|
slog.ErrorContext(request.Context(), "list units", "err", queryError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -36,15 +37,15 @@ func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item unitItem
|
var item unitItem
|
||||||
if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil {
|
if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil {
|
||||||
slog.Error("scan unit row", "err", scanError)
|
slog.ErrorContext(request.Context(), "scan unit row", "err", scanError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
if rowsError := rows.Err(); rowsError != nil {
|
if rowsError := rows.Err(); rowsError != nil {
|
||||||
slog.Error("iterate unit rows", "err", rowsError)
|
slog.ErrorContext(request.Context(), "iterate unit rows", "err", rowsError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to load units")
|
||||||
return
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(status)
|
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) {
|
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := h.service.GetProfile(r.Context(), userID)
|
u, err := h.service.GetProfile(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorJSON(w, http.StatusNotFound, "user not found")
|
writeErrorJSON(w, r, http.StatusNotFound, "user not found")
|
||||||
return
|
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) {
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||||
var req UpdateProfileRequest
|
var req UpdateProfileRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := h.service.UpdateProfile(r.Context(), userID, req)
|
u, err := h.service.UpdateProfile(r.Context(), userID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorJSON(w, http.StatusBadRequest, err.Error())
|
writeErrorJSON(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +59,17 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
if encodeErr := json.NewEncoder(w).Encode(errorResponse{
|
||||||
slog.Error("failed to write error response", "err", err)
|
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())
|
userID := middleware.UserIDFromCtx(request.Context())
|
||||||
userProducts, listError := handler.repo.List(request.Context(), userID)
|
userProducts, listError := handler.repo.List(request.Context(), userID)
|
||||||
if listError != nil {
|
if listError != nil {
|
||||||
slog.Error("list user products", "user_id", userID, "err", listError)
|
slog.ErrorContext(request.Context(), "list user products", "user_id", userID, "err", listError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list user products")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list user products")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if userProducts == nil {
|
if userProducts == nil {
|
||||||
@@ -50,18 +50,18 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http
|
|||||||
userID := middleware.UserIDFromCtx(request.Context())
|
userID := middleware.UserIDFromCtx(request.Context())
|
||||||
var req CreateRequest
|
var req CreateRequest
|
||||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.Name == "" {
|
if req.Name == "" {
|
||||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "name is required")
|
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userProduct, createError := handler.repo.Create(request.Context(), userID, req)
|
userProduct, createError := handler.repo.Create(request.Context(), userID, req)
|
||||||
if createError != nil {
|
if createError != nil {
|
||||||
slog.Error("create user product", "user_id", userID, "err", createError)
|
slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user product")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(responseWriter, http.StatusCreated, userProduct)
|
writeJSON(responseWriter, http.StatusCreated, userProduct)
|
||||||
@@ -72,7 +72,7 @@ func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request
|
|||||||
userID := middleware.UserIDFromCtx(request.Context())
|
userID := middleware.UserIDFromCtx(request.Context())
|
||||||
var items []CreateRequest
|
var items []CreateRequest
|
||||||
if decodeError := json.NewDecoder(request.Body).Decode(&items); decodeError != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
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)
|
userProducts, batchError := handler.repo.BatchCreate(request.Context(), userID, items)
|
||||||
if batchError != nil {
|
if batchError != nil {
|
||||||
slog.Error("batch create user products", "user_id", userID, "err", batchError)
|
slog.ErrorContext(request.Context(), "batch create user products", "user_id", userID, "err", batchError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user products")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user products")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(responseWriter, http.StatusCreated, userProducts)
|
writeJSON(responseWriter, http.StatusCreated, userProducts)
|
||||||
@@ -96,18 +96,18 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http
|
|||||||
|
|
||||||
var req UpdateRequest
|
var req UpdateRequest
|
||||||
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req)
|
userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req)
|
||||||
if errors.Is(updateError, ErrNotFound) {
|
if errors.Is(updateError, ErrNotFound) {
|
||||||
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found")
|
writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if updateError != nil {
|
if updateError != nil {
|
||||||
slog.Error("update user product", "id", id, "err", updateError)
|
slog.ErrorContext(request.Context(), "update user product", "id", id, "err", updateError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to update user product")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to update user product")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(responseWriter, http.StatusOK, userProduct)
|
writeJSON(responseWriter, http.StatusOK, userProduct)
|
||||||
@@ -120,11 +120,11 @@ func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http
|
|||||||
|
|
||||||
if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil {
|
if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil {
|
||||||
if errors.Is(deleteError, ErrNotFound) {
|
if errors.Is(deleteError, ErrNotFound) {
|
||||||
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found")
|
writeErrorJSON(responseWriter, request, http.StatusNotFound, "user product not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("delete user product", "id", id, "err", deleteError)
|
slog.ErrorContext(request.Context(), "delete user product", "id", id, "err", deleteError)
|
||||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to delete user product")
|
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete user product")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
responseWriter.WriteHeader(http.StatusNoContent)
|
responseWriter.WriteHeader(http.StatusNoContent)
|
||||||
@@ -132,12 +132,16 @@ func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http
|
|||||||
|
|
||||||
type errorResponse struct {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
responseWriter.WriteHeader(status)
|
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) {
|
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;
|
||||||
@@ -96,7 +96,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (isFutureDate)
|
if (isFutureDate)
|
||||||
_PlanningBanner(dateString: dateString)
|
_FutureDayHeader(dateString: dateString)
|
||||||
else ...[
|
else ...[
|
||||||
_CaloriesCard(
|
_CaloriesCard(
|
||||||
loggedCalories: loggedCalories,
|
loggedCalories: loggedCalories,
|
||||||
@@ -1143,6 +1143,163 @@ class _DiaryEntryTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Future day header (wraps banner + menu-gen CTA) ────────────
|
||||||
|
|
||||||
|
class _FutureDayHeader extends ConsumerWidget {
|
||||||
|
final String dateString;
|
||||||
|
const _FutureDayHeader({required this.dateString});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final date = DateTime.parse(dateString);
|
||||||
|
final weekString = isoWeekString(date);
|
||||||
|
final menuState = ref.watch(menuProvider(weekString));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_PlanningBanner(dateString: dateString),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
menuState.when(
|
||||||
|
loading: () => _GenerateLoadingCard(l10n: l10n),
|
||||||
|
error: (_, __) => _GenerateActionCard(
|
||||||
|
l10n: l10n,
|
||||||
|
onGenerate: () =>
|
||||||
|
ref.read(menuProvider(weekString).notifier).generate(),
|
||||||
|
),
|
||||||
|
data: (plan) => plan == null
|
||||||
|
? _GenerateActionCard(
|
||||||
|
l10n: l10n,
|
||||||
|
onGenerate: () =>
|
||||||
|
ref.read(menuProvider(weekString).notifier).generate(),
|
||||||
|
)
|
||||||
|
: _WeekPlannedChip(l10n: l10n),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenerateActionCard extends StatelessWidget {
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final VoidCallback onGenerate;
|
||||||
|
const _GenerateActionCard({required this.l10n, required this.onGenerate});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.auto_awesome,
|
||||||
|
color: theme.colorScheme.primary, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.generateWeekLabel,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.generateWeekSubtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onGenerate,
|
||||||
|
child: Text(l10n.generateWeekLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenerateLoadingCard extends StatelessWidget {
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
const _GenerateLoadingCard({required this.l10n});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(theme.colorScheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
l10n.generatingMenu,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeekPlannedChip extends StatelessWidget {
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
const _WeekPlannedChip({required this.l10n});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline,
|
||||||
|
color: theme.colorScheme.onSecondaryContainer, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
l10n.weekPlannedLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Planning banner (future dates) ────────────────────────────
|
// ── Planning banner (future dates) ────────────────────────────
|
||||||
|
|
||||||
class _PlanningBanner extends StatelessWidget {
|
class _PlanningBanner extends StatelessWidget {
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "وضع علامة كمأكول",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "مخطط",
|
||||||
|
"generateWeekLabel": "تخطيط الأسبوع",
|
||||||
|
"generateWeekSubtitle": "سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع",
|
||||||
|
"generatingMenu": "جارٍ إنشاء القائمة...",
|
||||||
|
"weekPlannedLabel": "تم تخطيط الأسبوع"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "Als gegessen markieren",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "Geplant",
|
||||||
|
"generateWeekLabel": "Woche planen",
|
||||||
|
"generateWeekSubtitle": "KI erstellt einen Menüplan mit Frühstück, Mittagessen und Abendessen für die ganze Woche",
|
||||||
|
"generatingMenu": "Menü wird erstellt...",
|
||||||
|
"weekPlannedLabel": "Woche geplant"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,5 +129,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "Mark as eaten",
|
"markAsEaten": "Mark as eaten",
|
||||||
"plannedMealLabel": "Planned"
|
"plannedMealLabel": "Planned",
|
||||||
|
"generateWeekLabel": "Plan the week",
|
||||||
|
"generateWeekSubtitle": "AI will create a menu with breakfast, lunch and dinner for the whole week",
|
||||||
|
"generatingMenu": "Generating menu...",
|
||||||
|
"weekPlannedLabel": "Week planned"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "Marcar como comido",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "Planificado",
|
||||||
|
"generateWeekLabel": "Planificar la semana",
|
||||||
|
"generateWeekSubtitle": "La IA creará un menú con desayuno, comida y cena para toda la semana",
|
||||||
|
"generatingMenu": "Generando menú...",
|
||||||
|
"weekPlannedLabel": "Semana planificada"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "Marquer comme mangé",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "Planifié",
|
||||||
|
"generateWeekLabel": "Planifier la semaine",
|
||||||
|
"generateWeekSubtitle": "L'IA créera un menu avec petit-déjeuner, déjeuner et dîner pour toute la semaine",
|
||||||
|
"generatingMenu": "Génération du menu...",
|
||||||
|
"weekPlannedLabel": "Semaine planifiée"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "खाया हुआ चिह्नित करें",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "नियोजित",
|
||||||
|
"generateWeekLabel": "सप्ताह की योजना बनाएं",
|
||||||
|
"generateWeekSubtitle": "AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा",
|
||||||
|
"generatingMenu": "मेनू बना रहे हैं...",
|
||||||
|
"weekPlannedLabel": "सप्ताह की योजना बनाई गई"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "Segna come mangiato",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "Pianificato",
|
||||||
|
"generateWeekLabel": "Pianifica la settimana",
|
||||||
|
"generateWeekSubtitle": "L'AI creerà un menu con colazione, pranzo e cena per tutta la settimana",
|
||||||
|
"generatingMenu": "Generazione menu...",
|
||||||
|
"weekPlannedLabel": "Settimana pianificata"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "食べた印をつける",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "予定済み",
|
||||||
|
"generateWeekLabel": "週を計画する",
|
||||||
|
"generateWeekSubtitle": "AIが一週間の朝食・昼食・夕食のメニューを作成します",
|
||||||
|
"generatingMenu": "メニューを生成中...",
|
||||||
|
"weekPlannedLabel": "週の計画済み"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "먹은 것으로 표시",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "계획됨",
|
||||||
|
"generateWeekLabel": "주간 계획하기",
|
||||||
|
"generateWeekSubtitle": "AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다",
|
||||||
|
"generatingMenu": "메뉴 생성 중...",
|
||||||
|
"weekPlannedLabel": "주간 계획 완료"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,6 +807,30 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Planned'**
|
/// **'Planned'**
|
||||||
String get plannedMealLabel;
|
String get plannedMealLabel;
|
||||||
|
|
||||||
|
/// No description provided for @generateWeekLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Plan the week'**
|
||||||
|
String get generateWeekLabel;
|
||||||
|
|
||||||
|
/// No description provided for @generateWeekSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'AI will create a menu with breakfast, lunch and dinner for the whole week'**
|
||||||
|
String get generateWeekSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @generatingMenu.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Generating menu...'**
|
||||||
|
String get generatingMenu;
|
||||||
|
|
||||||
|
/// No description provided for @weekPlannedLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Week planned'**
|
||||||
|
String get weekPlannedLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -355,8 +355,21 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'وضع علامة كمأكول';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'مخطط';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'تخطيط الأسبوع';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'جارٍ إنشاء القائمة...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'تم تخطيط الأسبوع';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,8 +357,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'Als gegessen markieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'Geplant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Woche planen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'KI erstellt einen Menüplan mit Frühstück, Mittagessen und Abendessen für die ganze Woche';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Menü wird erstellt...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Woche geplant';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,4 +359,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => 'Planned';
|
String get plannedMealLabel => 'Planned';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Plan the week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'AI will create a menu with breakfast, lunch and dinner for the whole week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Generating menu...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Week planned';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,8 +357,21 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'Marcar como comido';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'Planificado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Planificar la semana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'La IA creará un menú con desayuno, comida y cena para toda la semana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Generando menú...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Semana planificada';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,8 +358,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'Marquer comme mangé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'Planifié';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Planifier la semaine';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'L\'IA créera un menu avec petit-déjeuner, déjeuner et dîner pour toute la semaine';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Génération du menu...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Semaine planifiée';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,8 +356,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'खाया हुआ चिह्नित करें';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'नियोजित';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'सप्ताह की योजना बनाएं';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'मेनू बना रहे हैं...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'सप्ताह की योजना बनाई गई';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,8 +357,21 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'Segna come mangiato';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'Pianificato';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Pianifica la settimana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'L\'AI creerà un menu con colazione, pranzo e cena per tutta la settimana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Generazione menu...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Settimana pianificata';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,8 +354,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => '食べた印をつける';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => '予定済み';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => '週を計画する';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle => 'AIが一週間の朝食・昼食・夕食のメニューを作成します';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'メニューを生成中...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => '週の計画済み';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,8 +354,20 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => '먹은 것으로 표시';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => '계획됨';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => '주간 계획하기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle => 'AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => '메뉴 생성 중...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => '주간 계획 완료';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,8 +357,21 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => 'Marcar como comido';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => 'Planejado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Planejar a semana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'A IA criará um menu com café da manhã, almoço e jantar para a semana inteira';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Gerando menu...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Semana planejada';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,4 +359,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => 'Запланировано';
|
String get plannedMealLabel => 'Запланировано';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => 'Запланировать неделю';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle =>
|
||||||
|
'AI составит меню с завтраком, обедом и ужином на всю неделю';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => 'Генерируем меню...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => 'Неделя запланирована';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,8 +354,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get markAsEaten => '';
|
String get markAsEaten => '标记为已吃';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plannedMealLabel => '';
|
String get plannedMealLabel => '已计划';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekLabel => '规划本周';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generateWeekSubtitle => 'AI将为整周创建含早餐、午餐和晚餐的菜单';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get generatingMenu => '正在生成菜单...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get weekPlannedLabel => '本周已规划';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "Marcar como comido",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "Planejado",
|
||||||
|
"generateWeekLabel": "Planejar a semana",
|
||||||
|
"generateWeekSubtitle": "A IA criará um menu com café da manhã, almoço e jantar para a semana inteira",
|
||||||
|
"generatingMenu": "Gerando menu...",
|
||||||
|
"weekPlannedLabel": "Semana planejada"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,5 +129,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "Отметить как съеденное",
|
"markAsEaten": "Отметить как съеденное",
|
||||||
"plannedMealLabel": "Запланировано"
|
"plannedMealLabel": "Запланировано",
|
||||||
|
"generateWeekLabel": "Запланировать неделю",
|
||||||
|
"generateWeekSubtitle": "AI составит меню с завтраком, обедом и ужином на всю неделю",
|
||||||
|
"generatingMenu": "Генерируем меню...",
|
||||||
|
"weekPlannedLabel": "Неделя запланирована"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@
|
|||||||
"date": { "type": "String" }
|
"date": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"markAsEaten": "",
|
"markAsEaten": "标记为已吃",
|
||||||
"plannedMealLabel": ""
|
"plannedMealLabel": "已计划",
|
||||||
|
"generateWeekLabel": "规划本周",
|
||||||
|
"generateWeekSubtitle": "AI将为整周创建含早餐、午餐和晚餐的菜单",
|
||||||
|
"generatingMenu": "正在生成菜单...",
|
||||||
|
"weekPlannedLabel": "本周已规划"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,10 +125,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -689,26 +689,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1078,10 +1078,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.10"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user