refactor: introduce internal/domain/ layer, rename model.go → entity.go
Move all business-logic packages from internal/ root into internal/domain/: auth, cuisine, diary, dish, home, ingredient, language, menu, product, recipe, recognition, recommendation, savedrecipe, tag, units, user Rename model.go → entity.go in packages that hold domain entities: diary, dish, home, ingredient, menu, product, recipe, savedrecipe, user Update all import paths accordingly (adapters, infra/server, cmd/server, tests). No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
56
backend/internal/domain/menu/entity.go
Normal file
56
backend/internal/domain/menu/entity.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package menu
|
||||
|
||||
// MenuPlan is a weekly meal plan for a user.
|
||||
type MenuPlan struct {
|
||||
ID string `json:"id"`
|
||||
WeekStart string `json:"week_start"` // YYYY-MM-DD (Monday)
|
||||
Days []MenuDay `json:"days"`
|
||||
}
|
||||
|
||||
// MenuDay groups three meal slots for one calendar day.
|
||||
type MenuDay struct {
|
||||
Day int `json:"day"` // 1=Monday … 7=Sunday
|
||||
Date string `json:"date"`
|
||||
Meals []MealSlot `json:"meals"`
|
||||
TotalCalories float64 `json:"total_calories"`
|
||||
}
|
||||
|
||||
// MealSlot holds a single meal within a day.
|
||||
type MealSlot struct {
|
||||
ID string `json:"id"`
|
||||
MealType string `json:"meal_type"` // breakfast | lunch | dinner
|
||||
Recipe *MenuRecipe `json:"recipe,omitempty"`
|
||||
}
|
||||
|
||||
// MenuRecipe is a thin projection of a saved recipe used in the menu view.
|
||||
type MenuRecipe struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Nutrition NutritionInfo `json:"nutrition_per_serving"`
|
||||
}
|
||||
|
||||
// NutritionInfo holds macronutrient data.
|
||||
type NutritionInfo struct {
|
||||
Calories float64 `json:"calories"`
|
||||
ProteinG float64 `json:"protein_g"`
|
||||
FatG float64 `json:"fat_g"`
|
||||
CarbsG float64 `json:"carbs_g"`
|
||||
}
|
||||
|
||||
// PlanItem is the input needed to create one menu_items row.
|
||||
type PlanItem struct {
|
||||
DayOfWeek int
|
||||
MealType string
|
||||
RecipeID string
|
||||
}
|
||||
|
||||
// ShoppingItem is one entry in the shopping list.
|
||||
type ShoppingItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
Checked bool `json:"checked"`
|
||||
InStock float64 `json:"in_stock"`
|
||||
}
|
||||
584
backend/internal/domain/menu/handler.go
Normal file
584
backend/internal/domain/menu/handler.go
Normal file
@@ -0,0 +1,584 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/adapters/ai"
|
||||
"github.com/food-ai/backend/internal/domain/dish"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/domain/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// PhotoSearcher searches for a photo by query string.
|
||||
type PhotoSearcher interface {
|
||||
SearchPhoto(ctx context.Context, query string) (string, error)
|
||||
}
|
||||
|
||||
// UserLoader loads a user profile by ID.
|
||||
type UserLoader interface {
|
||||
GetByID(ctx context.Context, id string) (*user.User, error)
|
||||
}
|
||||
|
||||
// ProductLister returns human-readable product lines for the AI prompt.
|
||||
type ProductLister interface {
|
||||
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
|
||||
type RecipeSaver interface {
|
||||
Create(ctx context.Context, req dish.CreateRequest) (string, error)
|
||||
}
|
||||
|
||||
// MenuGenerator generates a 7-day meal plan via an AI provider.
|
||||
type MenuGenerator interface {
|
||||
GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error)
|
||||
}
|
||||
|
||||
// Handler handles menu and shopping-list endpoints.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
menuGenerator MenuGenerator
|
||||
pexels PhotoSearcher
|
||||
userLoader UserLoader
|
||||
productLister ProductLister
|
||||
recipeSaver RecipeSaver
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(
|
||||
repo *Repository,
|
||||
menuGenerator MenuGenerator,
|
||||
pexels PhotoSearcher,
|
||||
userLoader UserLoader,
|
||||
productLister ProductLister,
|
||||
recipeSaver RecipeSaver,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
menuGenerator: menuGenerator,
|
||||
pexels: pexels,
|
||||
userLoader: userLoader,
|
||||
productLister: productLister,
|
||||
recipeSaver: recipeSaver,
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Menu endpoints
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// GetMenu handles GET /menu?week=YYYY-WNN
|
||||
func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
slog.Error("get menu", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load menu")
|
||||
return
|
||||
}
|
||||
if plan == nil {
|
||||
// No plan yet — return empty response.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"week_start": weekStart,
|
||||
"days": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// GenerateMenu handles POST /ai/generate-menu
|
||||
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Week string `json:"week"` // optional, defaults to current week
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
weekStart, err := resolveWeekStart(body.Week)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
// Load user profile.
|
||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("load user for menu generation", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user profile")
|
||||
return
|
||||
}
|
||||
|
||||
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
|
||||
|
||||
// Attach pantry products.
|
||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||
menuReq.AvailableProducts = products
|
||||
}
|
||||
|
||||
// Generate 7-day plan via Gemini.
|
||||
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
|
||||
if err != nil {
|
||||
slog.Error("generate menu", "user_id", userID, "err", err)
|
||||
writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch Pexels images for all 21 recipes in parallel.
|
||||
type indexedRecipe struct {
|
||||
day int
|
||||
meal int
|
||||
imageURL string
|
||||
}
|
||||
imageResults := make([]indexedRecipe, 0, len(days)*3)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for di, day := range days {
|
||||
for mi := range day.Meals {
|
||||
wg.Add(1)
|
||||
go func(di, mi int, query string) {
|
||||
defer wg.Done()
|
||||
url, err := h.pexels.SearchPhoto(r.Context(), query)
|
||||
if err != nil {
|
||||
slog.Warn("pexels search failed", "query", query, "err", err)
|
||||
}
|
||||
mu.Lock()
|
||||
imageResults = append(imageResults, indexedRecipe{di, mi, url})
|
||||
mu.Unlock()
|
||||
}(di, mi, day.Meals[mi].Recipe.ImageQuery)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, res := range imageResults {
|
||||
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
|
||||
}
|
||||
|
||||
// Persist all 21 recipes as dish+recipe rows.
|
||||
type savedRef struct {
|
||||
day int
|
||||
meal int
|
||||
recipeID string
|
||||
}
|
||||
refs := make([]savedRef, 0, len(days)*3)
|
||||
for di, day := range days {
|
||||
for mi, meal := range day.Meals {
|
||||
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
|
||||
if err != nil {
|
||||
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save recipes")
|
||||
return
|
||||
}
|
||||
refs = append(refs, savedRef{di, mi, recipeID})
|
||||
}
|
||||
}
|
||||
|
||||
// Build PlanItems list in day/meal order.
|
||||
planItems := make([]PlanItem, 0, 21)
|
||||
for _, ref := range refs {
|
||||
planItems = append(planItems, PlanItem{
|
||||
DayOfWeek: days[ref.day].Day,
|
||||
MealType: days[ref.day].Meals[ref.meal].MealType,
|
||||
RecipeID: ref.recipeID,
|
||||
})
|
||||
}
|
||||
|
||||
// Persist in a single transaction.
|
||||
planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
|
||||
if err != nil {
|
||||
slog.Error("save menu plan", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save menu plan")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate shopping list.
|
||||
if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil {
|
||||
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil {
|
||||
slog.Warn("auto-generate shopping list", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the freshly saved plan.
|
||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil || plan == nil {
|
||||
slog.Error("load generated menu", "err", err, "plan_nil", plan == nil)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load generated menu")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// UpdateMenuItem handles PUT /menu/items/{id}
|
||||
func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
itemID := chi.URLParam(r, "id")
|
||||
|
||||
var body struct {
|
||||
RecipeID string `json:"recipe_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "recipe_id required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "menu item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("update menu item", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update menu item")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteMenuItem handles DELETE /menu/items/{id}
|
||||
func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
itemID := chi.URLParam(r, "id")
|
||||
if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "menu item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete menu item", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete menu item")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Shopping list endpoints
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// GenerateShoppingList handles POST /shopping-list/generate
|
||||
func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Week string `json:"week"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
weekStart, err := resolveWeekStart(body.Week)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "no menu plan found for this week")
|
||||
return
|
||||
}
|
||||
slog.Error("get plan id", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.buildShoppingList(r.Context(), planID)
|
||||
if err != nil {
|
||||
slog.Error("build shopping list", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to build shopping list")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
|
||||
slog.Error("upsert shopping list", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save shopping list")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// GetShoppingList handles GET /shopping-list?week=YYYY-WNN
|
||||
func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeJSON(w, http.StatusOK, []ShoppingItem{})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shopping list")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []ShoppingItem{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// ToggleShoppingItem handles PATCH /shopping-list/items/{index}/check
|
||||
func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
indexStr := chi.URLParam(r, "index")
|
||||
var index int
|
||||
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid item index")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Checked bool `json:"checked"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "menu plan not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
|
||||
slog.Error("toggle shopping item", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update item")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// buildShoppingList aggregates all ingredients from a plan's recipes.
|
||||
func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]ShoppingItem, error) {
|
||||
rows, err := h.repo.GetIngredientsByPlan(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type key struct{ name, unit string }
|
||||
totals := map[key]float64{}
|
||||
|
||||
for _, row := range rows {
|
||||
unit := ""
|
||||
if row.UnitCode != nil {
|
||||
unit = *row.UnitCode
|
||||
}
|
||||
k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit}
|
||||
totals[k] += row.Amount
|
||||
}
|
||||
|
||||
items := make([]ShoppingItem, 0, len(totals))
|
||||
for k, amount := range totals {
|
||||
items = append(items, ShoppingItem{
|
||||
Name: k.name,
|
||||
Category: "other",
|
||||
Amount: amount,
|
||||
Unit: k.unit,
|
||||
Checked: false,
|
||||
InStock: 0,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type userPreferences struct {
|
||||
Cuisines []string `json:"cuisines"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
}
|
||||
|
||||
func buildMenuRequest(u *user.User, lang string) ai.MenuRequest {
|
||||
req := ai.MenuRequest{DailyCalories: 2000, Lang: lang}
|
||||
if u.Goal != nil {
|
||||
req.UserGoal = *u.Goal
|
||||
}
|
||||
if u.DailyCalories != nil && *u.DailyCalories > 0 {
|
||||
req.DailyCalories = *u.DailyCalories
|
||||
}
|
||||
if len(u.Preferences) > 0 {
|
||||
var prefs userPreferences
|
||||
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
|
||||
req.CuisinePrefs = prefs.Cuisines
|
||||
req.Restrictions = prefs.Restrictions
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
|
||||
func recipeToCreateRequest(r ai.Recipe) dish.CreateRequest {
|
||||
cr := dish.CreateRequest{
|
||||
Name: r.Title,
|
||||
Description: r.Description,
|
||||
CuisineSlug: mapCuisineSlug(r.Cuisine),
|
||||
ImageURL: r.ImageURL,
|
||||
Difficulty: r.Difficulty,
|
||||
PrepTimeMin: r.PrepTimeMin,
|
||||
CookTimeMin: r.CookTimeMin,
|
||||
Servings: r.Servings,
|
||||
Calories: r.Nutrition.Calories,
|
||||
Protein: r.Nutrition.ProteinG,
|
||||
Fat: r.Nutrition.FatG,
|
||||
Carbs: r.Nutrition.CarbsG,
|
||||
Source: "menu",
|
||||
}
|
||||
for _, ing := range r.Ingredients {
|
||||
cr.Ingredients = append(cr.Ingredients, dish.IngredientInput{
|
||||
Name: ing.Name,
|
||||
Amount: ing.Amount,
|
||||
Unit: ing.Unit,
|
||||
})
|
||||
}
|
||||
for _, s := range r.Steps {
|
||||
cr.Steps = append(cr.Steps, dish.StepInput{
|
||||
Number: s.Number,
|
||||
Description: s.Description,
|
||||
TimerSeconds: s.TimerSeconds,
|
||||
})
|
||||
}
|
||||
cr.Tags = append(cr.Tags, r.Tags...)
|
||||
return cr
|
||||
}
|
||||
|
||||
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||
// Falls back to "other".
|
||||
func mapCuisineSlug(cuisine string) string {
|
||||
known := map[string]string{
|
||||
"russian": "russian",
|
||||
"italian": "italian",
|
||||
"french": "french",
|
||||
"chinese": "chinese",
|
||||
"japanese": "japanese",
|
||||
"korean": "korean",
|
||||
"mexican": "mexican",
|
||||
"mediterranean": "mediterranean",
|
||||
"indian": "indian",
|
||||
"thai": "thai",
|
||||
"american": "american",
|
||||
"georgian": "georgian",
|
||||
"spanish": "spanish",
|
||||
"german": "german",
|
||||
"middle_eastern": "middle_eastern",
|
||||
"turkish": "turkish",
|
||||
"greek": "greek",
|
||||
"vietnamese": "vietnamese",
|
||||
"asian": "other",
|
||||
"european": "other",
|
||||
}
|
||||
if s, ok := known[cuisine]; ok {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.
|
||||
func resolveWeekStart(week string) (string, error) {
|
||||
if week == "" {
|
||||
return currentWeekStart(), nil
|
||||
}
|
||||
var year, w int
|
||||
if _, err := fmt.Sscanf(week, "%d-W%d", &year, &w); err != nil || w < 1 || w > 53 {
|
||||
return "", fmt.Errorf("invalid week: %q", week)
|
||||
}
|
||||
t := mondayOfISOWeek(year, w)
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
func currentWeekStart() string {
|
||||
now := time.Now().UTC()
|
||||
year, week := now.ISOWeek()
|
||||
return mondayOfISOWeek(year, week).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func mondayOfISOWeek(year, week int) time.Time {
|
||||
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC)
|
||||
weekday := int(jan4.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
monday1 := jan4.AddDate(0, 0, 1-weekday)
|
||||
return monday1.AddDate(0, 0, (week-1)*7)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
325
backend/internal/domain/menu/repository.go
Normal file
325
backend/internal/domain/menu/repository.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a menu item is not found for the user.
|
||||
var ErrNotFound = errors.New("menu item not found")
|
||||
|
||||
// Repository handles persistence for menu plans, items, and shopping lists.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
|
||||
// Returns nil, nil when no plan exists for that week.
|
||||
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
|
||||
const q = `
|
||||
SELECT mp.id, mp.week_start::text,
|
||||
mi.id, mi.day_of_week, mi.meal_type,
|
||||
rec.id,
|
||||
COALESCE(dt.name, d.name),
|
||||
COALESCE(d.image_url, ''),
|
||||
rec.calories_per_serving,
|
||||
rec.protein_per_serving,
|
||||
rec.fat_per_serving,
|
||||
rec.carbs_per_serving
|
||||
FROM menu_plans mp
|
||||
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.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 = $3
|
||||
WHERE mp.user_id = $1 AND mp.week_start::text = $2
|
||||
ORDER BY mi.day_of_week,
|
||||
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get menu by week: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plan *MenuPlan
|
||||
dayMap := map[int]*MenuDay{}
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
planID, planWeekStart string
|
||||
itemID, mealType *string
|
||||
dow *int
|
||||
recipeID, title, imageURL *string
|
||||
calPer, protPer, fatPer, carbPer *float64
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&planID, &planWeekStart,
|
||||
&itemID, &dow, &mealType,
|
||||
&recipeID, &title, &imageURL,
|
||||
&calPer, &protPer, &fatPer, &carbPer,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan menu row: %w", err)
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
plan = &MenuPlan{ID: planID, WeekStart: planWeekStart}
|
||||
}
|
||||
|
||||
if itemID == nil || dow == nil || mealType == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
day, ok := dayMap[*dow]
|
||||
if !ok {
|
||||
day = &MenuDay{Day: *dow, Date: dayDate(planWeekStart, *dow)}
|
||||
dayMap[*dow] = day
|
||||
}
|
||||
|
||||
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
||||
if recipeID != nil && title != nil {
|
||||
nutrition := NutritionInfo{
|
||||
Calories: derefFloat(calPer),
|
||||
ProteinG: derefFloat(protPer),
|
||||
FatG: derefFloat(fatPer),
|
||||
CarbsG: derefFloat(carbPer),
|
||||
}
|
||||
slot.Recipe = &MenuRecipe{
|
||||
ID: *recipeID,
|
||||
Title: *title,
|
||||
ImageURL: derefStr(imageURL),
|
||||
Nutrition: nutrition,
|
||||
}
|
||||
day.TotalCalories += nutrition.Calories
|
||||
}
|
||||
day.Meals = append(day.Meals, slot)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Assemble days in order.
|
||||
for dow := 1; dow <= 7; dow++ {
|
||||
if d, ok := dayMap[dow]; ok {
|
||||
plan.Days = append(plan.Days, *d)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// SaveMenuInTx upserts a menu_plan row, wipes previous menu_items, and inserts
|
||||
// the new ones — all in a single transaction.
|
||||
func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var planID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO menu_plans (user_id, week_start)
|
||||
VALUES ($1, $2::date)
|
||||
ON CONFLICT (user_id, week_start) DO UPDATE SET created_at = now()
|
||||
RETURNING id`, userID, weekStart).Scan(&planID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upsert menu_plan: %w", err)
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE menu_plan_id = $1`, planID); err != nil {
|
||||
return "", fmt.Errorf("delete old menu items: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if _, err = tx.Exec(ctx, `
|
||||
INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
planID, item.DayOfWeek, item.MealType, item.RecipeID,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("insert menu item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
// UpdateItem replaces the recipe in a menu slot.
|
||||
func (r *Repository) UpdateItem(ctx context.Context, itemID, userID, recipeID string) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE menu_items mi
|
||||
SET recipe_id = $3
|
||||
FROM menu_plans mp
|
||||
WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`,
|
||||
itemID, userID, recipeID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update menu item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteItem removes a menu slot.
|
||||
func (r *Repository) DeleteItem(ctx context.Context, itemID, userID string) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM menu_items mi
|
||||
USING menu_plans mp
|
||||
WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`,
|
||||
itemID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete menu item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertShoppingList stores the shopping list for a menu plan.
|
||||
func (r *Repository) UpsertShoppingList(ctx context.Context, userID, planID string, items []ShoppingItem) error {
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal shopping items: %w", err)
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO shopping_lists (user_id, menu_plan_id, items)
|
||||
VALUES ($1, $2, $3::jsonb)
|
||||
ON CONFLICT (user_id, menu_plan_id) DO UPDATE
|
||||
SET items = EXCLUDED.items, generated_at = now()`,
|
||||
userID, planID, string(raw),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetShoppingList returns the shopping list for the user's plan.
|
||||
func (r *Repository) GetShoppingList(ctx context.Context, userID, planID string) ([]ShoppingItem, error) {
|
||||
var raw []byte
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT items FROM shopping_lists
|
||||
WHERE user_id = $1 AND menu_plan_id = $2`,
|
||||
userID, planID,
|
||||
).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get shopping list: %w", err)
|
||||
}
|
||||
var items []ShoppingItem
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal shopping items: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ToggleShoppingItem flips the checked flag for the item at the given index.
|
||||
func (r *Repository) ToggleShoppingItem(ctx context.Context, userID, planID string, index int, checked bool) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE shopping_lists
|
||||
SET items = jsonb_set(items, ARRAY[$1::text, 'checked'], to_jsonb($2::boolean))
|
||||
WHERE user_id = $3 AND menu_plan_id = $4`,
|
||||
fmt.Sprintf("%d", index), checked, userID, planID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("toggle shopping item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlanIDByWeek returns the menu_plan id for the user and given Monday date.
|
||||
func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart string) (string, error) {
|
||||
var id string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id FROM menu_plans WHERE user_id = $1 AND week_start::text = $2`,
|
||||
userID, weekStart,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get plan id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetIngredientsByPlan returns all ingredients from all recipes in the plan.
|
||||
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type
|
||||
FROM menu_items mi
|
||||
JOIN recipes rec ON rec.id = mi.recipe_id
|
||||
JOIN recipe_ingredients ri ON ri.recipe_id = rec.id
|
||||
WHERE mi.menu_plan_id = $1
|
||||
ORDER BY ri.sort_order`, planID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ingredients by plan: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ingredientRow
|
||||
for rows.Next() {
|
||||
var row ingredientRow
|
||||
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type ingredientRow struct {
|
||||
Name string
|
||||
Amount float64
|
||||
UnitCode *string
|
||||
MealType string
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func dayDate(weekStart string, dow int) string {
|
||||
t, err := time.Parse("2006-01-02", weekStart)
|
||||
if err != nil {
|
||||
return weekStart
|
||||
}
|
||||
return t.AddDate(0, 0, dow-1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func derefStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func derefFloat(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
}
|
||||
Reference in New Issue
Block a user