- Add internal/adapters/ai/types.go with neutral shared types (Recipe, DayPlan, RecognizedItem, IngredientClassification, etc.) - Remove types from internal/adapters/openai/ — adapter now uses ai.* - Define Recognizer interface in recognition package - Define MenuGenerator interface in menu package - Define RecipeGenerator interface in recommendation package - Handler structs now hold interfaces, not *openai.Client - Add wire.Bind entries for the three new interface bindings To swap OpenAI for another provider: implement the three interfaces using ai.* types and change the wire.Bind lines in cmd/server/wire.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
585 lines
17 KiB
Go
585 lines
17 KiB
Go
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/dish"
|
|
"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)
|
|
}
|
|
|
|
// 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)
|
|
}
|