Files
food-ai/backend/internal/menu/handler.go
dbastrikin 19a985ad49 refactor: restructure internal/ into adapters/, infra/, and app layers
- internal/gemini/ → internal/adapters/openai/ (renamed package to openai)
- internal/pexels/ → internal/adapters/pexels/
- internal/config/   → internal/infra/config/
- internal/database/ → internal/infra/database/
- internal/locale/   → internal/infra/locale/
- internal/middleware/ → internal/infra/middleware/
- internal/server/   → internal/infra/server/

All import paths and call sites updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:10:37 +02:00

580 lines
17 KiB
Go

package menu
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/adapters/openai"
"github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/food-ai/backend/internal/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)
}
// Handler handles menu and shopping-list endpoints.
type Handler struct {
repo *Repository
openaiClient *openai.Client
pexels PhotoSearcher
userLoader UserLoader
productLister ProductLister
recipeSaver RecipeSaver
}
// NewHandler creates a new Handler.
func NewHandler(
repo *Repository,
openaiClient *openai.Client,
pexels PhotoSearcher,
userLoader UserLoader,
productLister ProductLister,
recipeSaver RecipeSaver,
) *Handler {
return &Handler{
repo: repo,
openaiClient: openaiClient,
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.openaiClient.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) openai.MenuRequest {
req := openai.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 openai.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)
}