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

@@ -25,7 +25,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE),
// which prevents it from being used as a stored generated column.
const selectCols = `id, user_id, mapping_id, name, quantity, unit, category, storage_days, added_at,
const selectCols = `id, user_id, primary_ingredient_id, name, quantity, unit, category, storage_days, added_at,
(added_at + storage_days * INTERVAL '1 day') AS expires_at`
// List returns all products for a user, sorted by expires_at ASC.
@@ -57,11 +57,17 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
qty = 1
}
// Accept both new and legacy field names.
primaryID := req.PrimaryIngredientID
if primaryID == nil {
primaryID = req.MappingID
}
row := r.pool.QueryRow(ctx, `
INSERT INTO products (user_id, mapping_id, name, quantity, unit, category, storage_days)
INSERT INTO products (user_id, primary_ingredient_id, name, quantity, unit, category, storage_days)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+selectCols,
userID, req.MappingID, req.Name, qty, unit, req.Category, storageDays,
userID, primaryID, req.Name, qty, unit, req.Category, storageDays,
)
return scanProduct(row)
}
@@ -144,11 +150,11 @@ func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
switch {
case daysLeft <= 0:
line += " (истекает сегодня ⚠)"
line += " (expires today ⚠)"
case daysLeft == 1:
line += " (истекает завтра ⚠)"
line += " (expires tomorrow ⚠)"
case daysLeft <= 3:
line += fmt.Sprintf(" (истекает через %d дня ⚠)", daysLeft)
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
}
lines = append(lines, line)
}
@@ -160,7 +166,7 @@ func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string
func scanProduct(row pgx.Row) (*Product, error) {
var p Product
err := row.Scan(
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
)
if err != nil {
@@ -175,7 +181,7 @@ func collectProducts(rows pgx.Rows) ([]*Product, error) {
for rows.Next() {
var p Product
if err := rows.Scan(
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("scan product: %w", err)