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:
dbastrikin
2026-03-15 18:01:24 +02:00
parent 55d01400b0
commit 61feb91bba
52 changed files with 2479 additions and 1492 deletions

View File

@@ -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
}