feat: core schema redesign — dishes, structured recipes, cuisines, tags (iteration 7)
Replaces the flat JSONB-based recipe schema with a normalized relational model:
Schema (migrations consolidated to 001_initial_schema + 002_seed_data):
- New: dishes, dish_translations, dish_tags — canonical dish catalog
- New: cuisines, tags, dish_categories with _translations tables + full seed data
- New: recipe_ingredients, recipe_steps with _translations (replaces JSONB blobs)
- New: user_saved_recipes thin bookmark (drops saved_recipes + saved_recipe_translations)
- New: product_ingredients M2M table
- recipes: now a cooking variant of a dish (dish_id FK, no title/JSONB columns)
- recipe_translations: repurposed to per-language notes only
- products: mapping_id → primary_ingredient_id
- menu_items: recipe_id FK → recipes; adds dish_id
- meal_diary: adds dish_id, recipe_id → recipes, portion_g
Backend (Go):
- New packages: internal/cuisine, internal/tag, internal/dish (registry + handler + repo)
- New GET /cuisines, GET /tags (public), GET /dishes, GET /dishes/{id}, GET /recipes/{id}
- recipe, savedrecipe, menu, diary, product, ingredient packages updated for new schema
Flutter:
- New models: Cuisine, Tag; new providers: cuisineNamesProvider, tagNamesProvider
- recipe.dart: RecipeIngredient gains unit_code + effectiveUnit getter
- saved_recipe.dart: thin model, manual fromJson, computed nutrition getter
- diary_entry.dart: adds dishId, recipeId, portionG
- recipe_detail_screen.dart: localized cuisine/tag names via providers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -33,9 +33,9 @@ type ProductLister interface {
|
||||
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
// RecipeSaver persists a single recipe and returns the stored record.
|
||||
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
|
||||
type RecipeSaver interface {
|
||||
Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error)
|
||||
Create(ctx context.Context, req dish.CreateRequest) (string, error)
|
||||
}
|
||||
|
||||
// Handler handles menu and shopping-list endpoints.
|
||||
@@ -136,7 +136,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
menuReq.AvailableProducts = products
|
||||
}
|
||||
|
||||
// Generate 7-day plan via OpenAI.
|
||||
// Generate 7-day plan via Gemini.
|
||||
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
|
||||
if err != nil {
|
||||
slog.Error("generate menu", "user_id", userID, "err", err)
|
||||
@@ -175,7 +175,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
|
||||
}
|
||||
|
||||
// Save all 21 recipes to saved_recipes.
|
||||
// Persist all 21 recipes as dish+recipe rows.
|
||||
type savedRef struct {
|
||||
day int
|
||||
meal int
|
||||
@@ -184,13 +184,13 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
refs := make([]savedRef, 0, len(days)*3)
|
||||
for di, day := range days {
|
||||
for mi, meal := range day.Meals {
|
||||
saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe))
|
||||
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, saved.ID})
|
||||
refs = append(refs, savedRef{di, mi, recipeID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,37 +420,25 @@ func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]Shopp
|
||||
|
||||
type key struct{ name, unit string }
|
||||
totals := map[key]float64{}
|
||||
categories := map[string]string{} // name → category (from meal_type heuristic)
|
||||
|
||||
for _, row := range rows {
|
||||
var ingredients []struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
if len(row.IngredientsJSON) > 0 {
|
||||
if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, ing := range ingredients {
|
||||
k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit}
|
||||
totals[k] += ing.Amount
|
||||
if _, ok := categories[k.name]; !ok {
|
||||
categories[k.name] = "other"
|
||||
}
|
||||
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: categories[k.name],
|
||||
Amount: amount,
|
||||
Unit: k.unit,
|
||||
Checked: false,
|
||||
InStock: 0,
|
||||
Name: k.name,
|
||||
Category: "other",
|
||||
Amount: amount,
|
||||
Unit: k.unit,
|
||||
Checked: false,
|
||||
InStock: 0,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -479,26 +467,70 @@ func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
|
||||
return req
|
||||
}
|
||||
|
||||
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest {
|
||||
ingJSON, _ := json.Marshal(r.Ingredients)
|
||||
stepsJSON, _ := json.Marshal(r.Steps)
|
||||
tagsJSON, _ := json.Marshal(r.Tags)
|
||||
nutritionJSON, _ := json.Marshal(r.Nutrition)
|
||||
return savedrecipe.SaveRequest{
|
||||
Title: r.Title,
|
||||
// recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
|
||||
func recipeToCreateRequest(r gemini.Recipe) dish.CreateRequest {
|
||||
cr := dish.CreateRequest{
|
||||
Name: r.Title,
|
||||
Description: r.Description,
|
||||
Cuisine: r.Cuisine,
|
||||
CuisineSlug: mapCuisineSlug(r.Cuisine),
|
||||
ImageURL: r.ImageURL,
|
||||
Difficulty: r.Difficulty,
|
||||
PrepTimeMin: r.PrepTimeMin,
|
||||
CookTimeMin: r.CookTimeMin,
|
||||
Servings: r.Servings,
|
||||
ImageURL: r.ImageURL,
|
||||
Ingredients: ingJSON,
|
||||
Steps: stepsJSON,
|
||||
Tags: tagsJSON,
|
||||
Nutrition: nutritionJSON,
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -27,18 +28,28 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
// 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,
|
||||
sr.id, sr.title, COALESCE(sr.image_url, ''), sr.nutrition
|
||||
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 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 = $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)
|
||||
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get menu by week: %w", err)
|
||||
}
|
||||
@@ -49,16 +60,17 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
planID, planWeekStart string
|
||||
itemID, mealType *string
|
||||
dow *int
|
||||
recipeID, title, imageURL *string
|
||||
nutritionRaw []byte
|
||||
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, &nutritionRaw,
|
||||
&recipeID, &title, &imageURL,
|
||||
&calPer, &protPer, &fatPer, &carbPer,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan menu row: %w", err)
|
||||
}
|
||||
@@ -79,9 +91,11 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
||||
|
||||
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
||||
if recipeID != nil && title != nil {
|
||||
var nutrition NutritionInfo
|
||||
if len(nutritionRaw) > 0 {
|
||||
_ = json.Unmarshal(nutritionRaw, &nutrition)
|
||||
nutrition := NutritionInfo{
|
||||
Calories: derefFloat(calPer),
|
||||
ProteinG: derefFloat(protPer),
|
||||
FatG: derefFloat(fatPer),
|
||||
CarbsG: derefFloat(carbPer),
|
||||
}
|
||||
slot.Recipe = &MenuRecipe{
|
||||
ID: *recipeID,
|
||||
@@ -257,10 +271,12 @@ func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart stri
|
||||
// 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 sr.ingredients, sr.nutrition, mi.meal_type
|
||||
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type
|
||||
FROM menu_items mi
|
||||
JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
||||
WHERE mi.menu_plan_id = $1`, planID)
|
||||
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)
|
||||
}
|
||||
@@ -268,24 +284,20 @@ func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([
|
||||
|
||||
var result []ingredientRow
|
||||
for rows.Next() {
|
||||
var ingredientsRaw, nutritionRaw []byte
|
||||
var mealType string
|
||||
if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil {
|
||||
var row ingredientRow
|
||||
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ingredientRow{
|
||||
IngredientsJSON: ingredientsRaw,
|
||||
NutritionJSON: nutritionRaw,
|
||||
MealType: mealType,
|
||||
})
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type ingredientRow struct {
|
||||
IngredientsJSON []byte
|
||||
NutritionJSON []byte
|
||||
MealType string
|
||||
Name string
|
||||
Amount float64
|
||||
UnitCode *string
|
||||
MealType string
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -304,3 +316,10 @@ func derefStr(s *string) string {
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func derefFloat(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user