From 61feb91bbadfb5fdec595ff19963ba4ce5e55739 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 15 Mar 2026 18:01:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20core=20schema=20redesign=20=E2=80=94=20?= =?UTF-8?q?dishes,=20structured=20recipes,=20cuisines,=20tags=20(iteration?= =?UTF-8?q?=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 46 ++ backend/cmd/server/main.go | 28 +- backend/internal/cuisine/handler.go | 28 + backend/internal/cuisine/registry.go | 80 +++ backend/internal/diary/model.go | 16 +- backend/internal/diary/repository.go | 12 +- backend/internal/dish/handler.go | 67 +++ backend/internal/dish/model.go | 96 ++++ backend/internal/dish/repository.go | 370 ++++++++++++++ backend/internal/ingredient/repository.go | 26 +- backend/internal/menu/handler.go | 118 +++-- backend/internal/menu/repository.go | 71 ++- backend/internal/product/model.go | 38 +- backend/internal/product/repository.go | 22 +- backend/internal/recipe/handler.go | 58 +++ backend/internal/recipe/model.go | 62 +-- backend/internal/recipe/repository.go | 221 +++----- .../recipe/repository_integration_test.go | 295 +---------- backend/internal/savedrecipe/handler.go | 7 +- backend/internal/savedrecipe/model.go | 103 ++-- backend/internal/savedrecipe/repository.go | 479 +++++++++++++----- backend/internal/server/server.go | 15 + backend/internal/tag/handler.go | 28 + backend/internal/tag/registry.go | 79 +++ backend/migrations/001_create_users.sql | 80 --- backend/migrations/001_initial_schema.sql | 452 +++++++++++++++++ .../002_create_ingredient_mappings.sql | 36 -- backend/migrations/002_seed_data.sql | 190 +++++++ backend/migrations/003_create_recipes.sql | 58 --- .../migrations/004_create_saved_recipes.sql | 25 - .../005_add_ingredient_search_indexes.sql | 12 - backend/migrations/006_create_products.sql | 19 - backend/migrations/007_create_menu_plans.sql | 35 -- backend/migrations/008_create_meal_diary.sql | 22 - .../009_create_translation_tables.sql | 118 ----- backend/migrations/010_drop_ru_columns.sql | 36 -- .../011_replace_age_with_date_of_birth.sql | 13 - .../migrations/012_refactor_ingredients.sql | 89 ---- backend/migrations/013_create_languages.sql | 24 - backend/migrations/014_create_units.sql | 58 --- client/lib/core/api/cuisine_repository.dart | 22 + client/lib/core/api/tag_repository.dart | 22 + client/lib/core/locale/cuisine_provider.dart | 19 + client/lib/core/locale/tag_provider.dart | 19 + .../recipes/recipe_detail_screen.dart | 32 +- client/lib/shared/models/cuisine.dart | 13 + client/lib/shared/models/diary_entry.dart | 6 + client/lib/shared/models/recipe.dart | 13 +- client/lib/shared/models/recipe.g.dart | 4 +- client/lib/shared/models/saved_recipe.dart | 118 +++-- client/lib/shared/models/saved_recipe.g.dart | 58 +-- client/lib/shared/models/tag.dart | 13 + 52 files changed, 2479 insertions(+), 1492 deletions(-) create mode 100644 backend/internal/cuisine/handler.go create mode 100644 backend/internal/cuisine/registry.go create mode 100644 backend/internal/dish/handler.go create mode 100644 backend/internal/dish/model.go create mode 100644 backend/internal/dish/repository.go create mode 100644 backend/internal/recipe/handler.go create mode 100644 backend/internal/tag/handler.go create mode 100644 backend/internal/tag/registry.go delete mode 100644 backend/migrations/001_create_users.sql create mode 100644 backend/migrations/001_initial_schema.sql delete mode 100644 backend/migrations/002_create_ingredient_mappings.sql create mode 100644 backend/migrations/002_seed_data.sql delete mode 100644 backend/migrations/003_create_recipes.sql delete mode 100644 backend/migrations/004_create_saved_recipes.sql delete mode 100644 backend/migrations/005_add_ingredient_search_indexes.sql delete mode 100644 backend/migrations/006_create_products.sql delete mode 100644 backend/migrations/007_create_menu_plans.sql delete mode 100644 backend/migrations/008_create_meal_diary.sql delete mode 100644 backend/migrations/009_create_translation_tables.sql delete mode 100644 backend/migrations/010_drop_ru_columns.sql delete mode 100644 backend/migrations/011_replace_age_with_date_of_birth.sql delete mode 100644 backend/migrations/012_refactor_ingredients.sql delete mode 100644 backend/migrations/013_create_languages.sql delete mode 100644 backend/migrations/014_create_units.sql create mode 100644 client/lib/core/api/cuisine_repository.dart create mode 100644 client/lib/core/api/tag_repository.dart create mode 100644 client/lib/core/locale/cuisine_provider.dart create mode 100644 client/lib/core/locale/tag_provider.dart create mode 100644 client/lib/shared/models/cuisine.dart create mode 100644 client/lib/shared/models/tag.dart diff --git a/CLAUDE.md b/CLAUDE.md index b00fda5..2b76010 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,3 +4,49 @@ - All code comments must be written in **English**. - All git commit messages must be written in **English**. + +## Localisation + +**Rule:** Every table that stores human-readable text must have a companion `{table}_translations` +table. The base table always contains the English canonical text (used as fallback). +Translations live only in `_translations` tables — never as extra columns (`name_ru`, `title_de`). + +### SQL pattern + +```sql +-- Base table — English canonical text always present +CREATE TABLE cuisines ( + slug VARCHAR(50) PRIMARY KEY, + name TEXT NOT NULL, + sort_order SMALLINT NOT NULL DEFAULT 0 +); +-- Translation companion +CREATE TABLE cuisine_translations ( + cuisine_slug VARCHAR(50) REFERENCES cuisines(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (cuisine_slug, lang) +); +``` + +### Query pattern + +```sql +SELECT COALESCE(ct.name, c.name) AS name +FROM cuisines c +LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $lang +``` + +### Already compliant tables + +`units` + `unit_translations`, `ingredient_categories` + `ingredient_category_translations`, +`ingredients` + `ingredient_translations` + `ingredient_aliases`, +`recipes` + `recipe_translations`, `cuisines` + `cuisine_translations`, +`tags` + `tag_translations`, `dish_categories` + `dish_category_translations`, +`dishes` + `dish_translations`, `recipe_ingredients` + `recipe_ingredient_translations`, +`recipe_steps` + `recipe_step_translations`. + +### Rule when adding new entities + +When adding a new entity with text fields, always create a `{table}_translations` +companion table and use LEFT JOIN COALESCE in all read queries. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a009ca9..162e5c6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -12,8 +12,10 @@ import ( "github.com/food-ai/backend/internal/auth" "github.com/food-ai/backend/internal/config" + "github.com/food-ai/backend/internal/cuisine" "github.com/food-ai/backend/internal/database" "github.com/food-ai/backend/internal/diary" + "github.com/food-ai/backend/internal/dish" "github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/ingredient" @@ -23,10 +25,12 @@ import ( "github.com/food-ai/backend/internal/units" "github.com/food-ai/backend/internal/pexels" "github.com/food-ai/backend/internal/product" + "github.com/food-ai/backend/internal/recipe" "github.com/food-ai/backend/internal/recognition" "github.com/food-ai/backend/internal/recommendation" "github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/server" + "github.com/food-ai/backend/internal/tag" "github.com/food-ai/backend/internal/user" ) @@ -84,6 +88,16 @@ func run() error { } slog.Info("units loaded", "count", len(units.Records)) + if err := cuisine.LoadFromDB(ctx, pool); err != nil { + return fmt.Errorf("load cuisines: %w", err) + } + slog.Info("cuisines loaded", "count", len(cuisine.Records)) + + if err := tag.LoadFromDB(ctx, pool); err != nil { + return fmt.Errorf("load tags: %w", err) + } + slog.Info("tags loaded", "count", len(tag.Records)) + // Firebase auth firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile) if err != nil { @@ -123,13 +137,21 @@ func run() error { // Recommendation domain recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo) + // Dish domain + dishRepo := dish.NewRepository(pool) + dishHandler := dish.NewHandler(dishRepo) + + // Recipe domain + recipeRepo := recipe.NewRepository(pool) + recipeHandler := recipe.NewHandler(recipeRepo) + // Saved recipes domain - savedRecipeRepo := savedrecipe.NewRepository(pool) + savedRecipeRepo := savedrecipe.NewRepository(pool, dishRepo) savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo) // Menu domain menuRepo := menu.NewRepository(pool) - menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, savedRecipeRepo) + menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, dishRepo) // Diary domain diaryRepo := diary.NewRepository(pool) @@ -151,6 +173,8 @@ func run() error { menuHandler, diaryHandler, homeHandler, + dishHandler, + recipeHandler, authMW, cfg.AllowedOrigins, ) diff --git a/backend/internal/cuisine/handler.go b/backend/internal/cuisine/handler.go new file mode 100644 index 0000000..23c7326 --- /dev/null +++ b/backend/internal/cuisine/handler.go @@ -0,0 +1,28 @@ +package cuisine + +import ( + "encoding/json" + "net/http" + + "github.com/food-ai/backend/internal/locale" +) + +type cuisineItem struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// List handles GET /cuisines — returns cuisines with names in the requested language. +func List(w http.ResponseWriter, r *http.Request) { + lang := locale.FromContext(r.Context()) + items := make([]cuisineItem, 0, len(Records)) + for _, c := range Records { + name, ok := c.Translations[lang] + if !ok { + name = c.Name + } + items = append(items, cuisineItem{Slug: c.Slug, Name: name}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"cuisines": items}) +} diff --git a/backend/internal/cuisine/registry.go b/backend/internal/cuisine/registry.go new file mode 100644 index 0000000..5974487 --- /dev/null +++ b/backend/internal/cuisine/registry.go @@ -0,0 +1,80 @@ +package cuisine + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Record is a cuisine loaded from DB with all its translations. +type Record struct { + Slug string + Name string // English canonical name + SortOrder int + // Translations maps lang code to localized name. + Translations map[string]string +} + +// Records is the ordered list of cuisines, populated by LoadFromDB at startup. +var Records []Record + +// LoadFromDB queries cuisines + cuisine_translations and populates Records. +func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error { + rows, err := pool.Query(ctx, ` + SELECT c.slug, c.name, c.sort_order, ct.lang, ct.name + FROM cuisines c + LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug + ORDER BY c.sort_order, ct.lang`) + if err != nil { + return fmt.Errorf("load cuisines from db: %w", err) + } + defer rows.Close() + + bySlug := map[string]*Record{} + var order []string + for rows.Next() { + var slug, engName string + var sortOrder int + var lang, name *string + if err := rows.Scan(&slug, &engName, &sortOrder, &lang, &name); err != nil { + return err + } + if _, ok := bySlug[slug]; !ok { + bySlug[slug] = &Record{ + Slug: slug, + Name: engName, + SortOrder: sortOrder, + Translations: map[string]string{}, + } + order = append(order, slug) + } + if lang != nil && name != nil { + bySlug[slug].Translations[*lang] = *name + } + } + if err := rows.Err(); err != nil { + return err + } + + result := make([]Record, 0, len(order)) + for _, slug := range order { + result = append(result, *bySlug[slug]) + } + Records = result + return nil +} + +// NameFor returns the localized name for a cuisine slug. +// Falls back to the English canonical name. +func NameFor(slug, lang string) string { + for _, c := range Records { + if c.Slug == slug { + if name, ok := c.Translations[lang]; ok { + return name + } + return c.Name + } + } + return slug +} diff --git a/backend/internal/diary/model.go b/backend/internal/diary/model.go index a9c366a..95db2e2 100644 --- a/backend/internal/diary/model.go +++ b/backend/internal/diary/model.go @@ -14,20 +14,24 @@ type Entry struct { FatG *float64 `json:"fat_g,omitempty"` CarbsG *float64 `json:"carbs_g,omitempty"` Source string `json:"source"` + DishID *string `json:"dish_id,omitempty"` RecipeID *string `json:"recipe_id,omitempty"` + PortionG *float64 `json:"portion_g,omitempty"` CreatedAt time.Time `json:"created_at"` } // CreateRequest is the body for POST /diary. type CreateRequest struct { - Date string `json:"date"` - MealType string `json:"meal_type"` - Name string `json:"name"` - Portions float64 `json:"portions"` + Date string `json:"date"` + MealType string `json:"meal_type"` + Name string `json:"name"` + Portions float64 `json:"portions"` Calories *float64 `json:"calories"` ProteinG *float64 `json:"protein_g"` FatG *float64 `json:"fat_g"` CarbsG *float64 `json:"carbs_g"` - Source string `json:"source"` - RecipeID *string `json:"recipe_id"` + Source string `json:"source"` + DishID *string `json:"dish_id"` + RecipeID *string `json:"recipe_id"` + PortionG *float64 `json:"portion_g"` } diff --git a/backend/internal/diary/repository.go b/backend/internal/diary/repository.go index 623a3f1..f7a3195 100644 --- a/backend/internal/diary/repository.go +++ b/backend/internal/diary/repository.go @@ -27,7 +27,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En rows, err := r.pool.Query(ctx, ` SELECT id, date::text, meal_type, name, portions, calories, protein_g, fat_g, carbs_g, - source, recipe_id, created_at + source, dish_id, recipe_id, portion_g, created_at FROM meal_diary WHERE user_id = $1 AND date = $2::date ORDER BY created_at ASC`, userID, date) @@ -60,13 +60,13 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques row := r.pool.QueryRow(ctx, ` INSERT INTO meal_diary (user_id, date, meal_type, name, portions, - calories, protein_g, fat_g, carbs_g, source, recipe_id) - VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11) + calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g) + VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, date::text, meal_type, name, portions, - calories, protein_g, fat_g, carbs_g, source, recipe_id, created_at`, + calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`, userID, req.Date, req.MealType, req.Name, portions, req.Calories, req.ProteinG, req.FatG, req.CarbsG, - source, req.RecipeID, + source, req.DishID, req.RecipeID, req.PortionG, ) return scanEntry(row) } @@ -95,7 +95,7 @@ func scanEntry(s scannable) (*Entry, error) { err := s.Scan( &e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions, &e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG, - &e.Source, &e.RecipeID, &e.CreatedAt, + &e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound diff --git a/backend/internal/dish/handler.go b/backend/internal/dish/handler.go new file mode 100644 index 0000000..582f0ee --- /dev/null +++ b/backend/internal/dish/handler.go @@ -0,0 +1,67 @@ +package dish + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// Handler handles HTTP requests for dishes. +type Handler struct { + repo *Repository +} + +// NewHandler creates a new Handler. +func NewHandler(repo *Repository) *Handler { + return &Handler{repo: repo} +} + +// List handles GET /dishes — returns all dishes (no recipe variants). +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + dishes, err := h.repo.List(r.Context()) + if err != nil { + slog.Error("list dishes", "err", err) + writeError(w, http.StatusInternalServerError, "failed to list dishes") + return + } + if dishes == nil { + dishes = []*Dish{} + } + writeJSON(w, http.StatusOK, map[string]any{"dishes": dishes}) +} + +// GetByID handles GET /dishes/{id} — returns a dish with all recipe variants. +func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dish, err := h.repo.GetByID(r.Context(), id) + if err != nil { + slog.Error("get dish", "id", id, "err", err) + writeError(w, http.StatusInternalServerError, "failed to get dish") + return + } + if dish == nil { + writeError(w, http.StatusNotFound, "dish not found") + return + } + writeJSON(w, http.StatusOK, dish) +} + +// --- helpers --- + +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) +} diff --git a/backend/internal/dish/model.go b/backend/internal/dish/model.go new file mode 100644 index 0000000..455b537 --- /dev/null +++ b/backend/internal/dish/model.go @@ -0,0 +1,96 @@ +package dish + +import "time" + +// Dish is a canonical dish record combining dish metadata with optional recipes. +type Dish struct { + ID string `json:"id"` + CuisineSlug *string `json:"cuisine_slug"` + CategorySlug *string `json:"category_slug"` + Name string `json:"name"` + Description *string `json:"description"` + ImageURL *string `json:"image_url"` + Tags []string `json:"tags"` + AvgRating float64 `json:"avg_rating"` + ReviewCount int `json:"review_count"` + Recipes []Recipe `json:"recipes,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Recipe is a cooking variant attached to a Dish. +type Recipe struct { + ID string `json:"id"` + DishID string `json:"dish_id"` + Source string `json:"source"` + Difficulty *string `json:"difficulty"` + PrepTimeMin *int `json:"prep_time_min"` + CookTimeMin *int `json:"cook_time_min"` + Servings *int `json:"servings"` + CaloriesPerServing *float64 `json:"calories_per_serving"` + ProteinPerServing *float64 `json:"protein_per_serving"` + FatPerServing *float64 `json:"fat_per_serving"` + CarbsPerServing *float64 `json:"carbs_per_serving"` + FiberPerServing *float64 `json:"fiber_per_serving"` + Ingredients []RecipeIngredient `json:"ingredients"` + Steps []RecipeStep `json:"steps"` + Notes *string `json:"notes,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RecipeIngredient is a single ingredient row from recipe_ingredients. +type RecipeIngredient struct { + ID string `json:"id"` + IngredientID *string `json:"ingredient_id"` + Name string `json:"name"` + Amount float64 `json:"amount"` + UnitCode *string `json:"unit_code"` + IsOptional bool `json:"is_optional"` + SortOrder int `json:"sort_order"` +} + +// RecipeStep is a single step row from recipe_steps. +type RecipeStep struct { + ID string `json:"id"` + StepNumber int `json:"step_number"` + Description string `json:"description"` + TimerSeconds *int `json:"timer_seconds"` + ImageURL *string `json:"image_url"` +} + +// CreateRequest is the body used to create a new dish + recipe at once. +// Used when saving a Gemini-generated recommendation. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + CuisineSlug string `json:"cuisine_slug"` + ImageURL string `json:"image_url"` + Tags []string `json:"tags"` + Source string `json:"source"` + Difficulty string `json:"difficulty"` + PrepTimeMin int `json:"prep_time_min"` + CookTimeMin int `json:"cook_time_min"` + Servings int `json:"servings"` + Calories float64 `json:"calories_per_serving"` + Protein float64 `json:"protein_per_serving"` + Fat float64 `json:"fat_per_serving"` + Carbs float64 `json:"carbs_per_serving"` + Ingredients []IngredientInput `json:"ingredients"` + Steps []StepInput `json:"steps"` +} + +// IngredientInput is a single ingredient in the create request. +type IngredientInput struct { + Name string `json:"name"` + Amount float64 `json:"amount"` + Unit string `json:"unit"` + IsOptional bool `json:"is_optional"` +} + +// StepInput is a single step in the create request. +type StepInput struct { + Number int `json:"number"` + Description string `json:"description"` + TimerSeconds *int `json:"timer_seconds"` +} diff --git a/backend/internal/dish/repository.go b/backend/internal/dish/repository.go new file mode 100644 index 0000000..834dfbb --- /dev/null +++ b/backend/internal/dish/repository.go @@ -0,0 +1,370 @@ +package dish + +import ( + "context" + "errors" + "fmt" + + "github.com/food-ai/backend/internal/locale" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Repository handles persistence for dishes and their recipes. +type Repository struct { + pool *pgxpool.Pool +} + +// NewRepository creates a new Repository. +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +// GetByID returns a dish with all its tag slugs and recipe variants. +// Text is resolved for the language in ctx (English fallback). +// Returns nil, nil if not found. +func (r *Repository) GetByID(ctx context.Context, id string) (*Dish, error) { + lang := locale.FromContext(ctx) + + const q = ` + SELECT d.id, + d.cuisine_slug, d.category_slug, + COALESCE(dt.name, d.name) AS name, + COALESCE(dt.description, d.description) AS description, + d.image_url, d.avg_rating, d.review_count, + d.created_at, d.updated_at + FROM dishes d + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 + WHERE d.id = $1` + + row := r.pool.QueryRow(ctx, q, id, lang) + dish, err := scanDish(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get dish %s: %w", id, err) + } + + if err := r.loadTags(ctx, dish); err != nil { + return nil, err + } + if err := r.loadRecipes(ctx, dish, lang); err != nil { + return nil, err + } + return dish, nil +} + +// List returns all dishes with tag slugs (no recipe variants). +// Text is resolved for the language in ctx. +func (r *Repository) List(ctx context.Context) ([]*Dish, error) { + lang := locale.FromContext(ctx) + + const q = ` + SELECT d.id, + d.cuisine_slug, d.category_slug, + COALESCE(dt.name, d.name) AS name, + COALESCE(dt.description, d.description) AS description, + d.image_url, d.avg_rating, d.review_count, + d.created_at, d.updated_at + FROM dishes d + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $1 + ORDER BY d.updated_at DESC` + + rows, err := r.pool.Query(ctx, q, lang) + if err != nil { + return nil, fmt.Errorf("list dishes: %w", err) + } + defer rows.Close() + + var dishes []*Dish + for rows.Next() { + d, err := scanDish(rows) + if err != nil { + return nil, fmt.Errorf("scan dish: %w", err) + } + dishes = append(dishes, d) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Load tags for each dish. + for _, d := range dishes { + if err := r.loadTags(ctx, d); err != nil { + return nil, err + } + } + return dishes, nil +} + +// Create creates a new dish + recipe row from a CreateRequest. +// Returns the recipe ID to be used in menu_items or user_saved_recipes. +func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return "", fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + // Resolve cuisine slug (empty string → NULL). + cuisineSlug := nullableStr(req.CuisineSlug) + + // Insert dish. + var dishID string + err = tx.QueryRow(ctx, ` + INSERT INTO dishes (cuisine_slug, name, description, image_url) + VALUES ($1, $2, NULLIF($3,''), NULLIF($4,'')) + RETURNING id`, + cuisineSlug, req.Name, req.Description, req.ImageURL, + ).Scan(&dishID) + if err != nil { + return "", fmt.Errorf("insert dish: %w", err) + } + + // Insert tags. + for _, slug := range req.Tags { + if _, err := tx.Exec(ctx, + `INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + dishID, slug, + ); err != nil { + return "", fmt.Errorf("insert dish tag %s: %w", slug, err) + } + } + + // Insert recipe. + source := req.Source + if source == "" { + source = "ai" + } + difficulty := nullableStr(req.Difficulty) + prepTime := nullableInt(req.PrepTimeMin) + cookTime := nullableInt(req.CookTimeMin) + servings := nullableInt(req.Servings) + calories := nullableFloat(req.Calories) + protein := nullableFloat(req.Protein) + fat := nullableFloat(req.Fat) + carbs := nullableFloat(req.Carbs) + + err = tx.QueryRow(ctx, ` + INSERT INTO recipes (dish_id, source, difficulty, prep_time_min, cook_time_min, servings, + calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + dishID, source, difficulty, prepTime, cookTime, servings, + calories, protein, fat, carbs, + ).Scan(&recipeID) + if err != nil { + return "", fmt.Errorf("insert recipe: %w", err) + } + + // Insert recipe_ingredients. + for i, ing := range req.Ingredients { + if _, err := tx.Exec(ctx, ` + INSERT INTO recipe_ingredients (recipe_id, name, amount, unit_code, is_optional, sort_order) + VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`, + recipeID, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i, + ); err != nil { + return "", fmt.Errorf("insert ingredient %d: %w", i, err) + } + } + + // Insert recipe_steps. + for _, s := range req.Steps { + num := s.Number + if num <= 0 { + num = 1 + } + if _, err := tx.Exec(ctx, ` + INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description) + VALUES ($1, $2, $3, $4)`, + recipeID, num, s.TimerSeconds, s.Description, + ); err != nil { + return "", fmt.Errorf("insert step %d: %w", num, err) + } + } + + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit: %w", err) + } + return recipeID, nil +} + +// loadTags fills d.Tags with the slugs from dish_tags. +func (r *Repository) loadTags(ctx context.Context, d *Dish) error { + rows, err := r.pool.Query(ctx, + `SELECT tag_slug FROM dish_tags WHERE dish_id = $1 ORDER BY tag_slug`, d.ID) + if err != nil { + return fmt.Errorf("load tags for dish %s: %w", d.ID, err) + } + defer rows.Close() + for rows.Next() { + var slug string + if err := rows.Scan(&slug); err != nil { + return err + } + d.Tags = append(d.Tags, slug) + } + return rows.Err() +} + +// loadRecipes fills d.Recipes with all recipe variants for the dish, +// including their relational ingredients and steps. +func (r *Repository) loadRecipes(ctx context.Context, d *Dish, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT r.id, r.dish_id, r.source, r.difficulty, + r.prep_time_min, r.cook_time_min, r.servings, + r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, + r.carbs_per_serving, r.fiber_per_serving, + rt.notes, + r.created_at, r.updated_at + FROM recipes r + LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2 + WHERE r.dish_id = $1 + ORDER BY r.created_at DESC`, d.ID, lang) + if err != nil { + return fmt.Errorf("load recipes for dish %s: %w", d.ID, err) + } + defer rows.Close() + + for rows.Next() { + rec, err := scanRecipe(rows) + if err != nil { + return fmt.Errorf("scan recipe: %w", err) + } + d.Recipes = append(d.Recipes, *rec) + } + if err := rows.Err(); err != nil { + return err + } + + // Load ingredients and steps for each recipe. + for i := range d.Recipes { + if err := r.loadIngredients(ctx, &d.Recipes[i], lang); err != nil { + return err + } + if err := r.loadSteps(ctx, &d.Recipes[i], lang); err != nil { + return err + } + } + return nil +} + +// loadIngredients fills rec.Ingredients from recipe_ingredients. +func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT ri.id, ri.ingredient_id, + COALESCE(rit.name, ri.name) AS name, + ri.amount, ri.unit_code, ri.is_optional, ri.sort_order + FROM recipe_ingredients ri + LEFT JOIN recipe_ingredient_translations rit + ON rit.ri_id = ri.id AND rit.lang = $2 + WHERE ri.recipe_id = $1 + ORDER BY ri.sort_order`, rec.ID, lang) + if err != nil { + return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err) + } + defer rows.Close() + + for rows.Next() { + var ing RecipeIngredient + if err := rows.Scan( + &ing.ID, &ing.IngredientID, &ing.Name, + &ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder, + ); err != nil { + return fmt.Errorf("scan ingredient: %w", err) + } + rec.Ingredients = append(rec.Ingredients, ing) + } + return rows.Err() +} + +// loadSteps fills rec.Steps from recipe_steps. +func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT rs.id, rs.step_number, + COALESCE(rst.description, rs.description) AS description, + rs.timer_seconds, rs.image_url + FROM recipe_steps rs + LEFT JOIN recipe_step_translations rst + ON rst.step_id = rs.id AND rst.lang = $2 + WHERE rs.recipe_id = $1 + ORDER BY rs.step_number`, rec.ID, lang) + if err != nil { + return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err) + } + defer rows.Close() + + for rows.Next() { + var s RecipeStep + if err := rows.Scan( + &s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL, + ); err != nil { + return fmt.Errorf("scan step: %w", err) + } + rec.Steps = append(rec.Steps, s) + } + return rows.Err() +} + +// --- scan helpers --- + +type scannable interface { + Scan(dest ...any) error +} + +func scanDish(s scannable) (*Dish, error) { + var d Dish + err := s.Scan( + &d.ID, &d.CuisineSlug, &d.CategorySlug, + &d.Name, &d.Description, &d.ImageURL, + &d.AvgRating, &d.ReviewCount, + &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + return nil, err + } + d.Tags = []string{} + return &d, nil +} + +func scanRecipe(s scannable) (*Recipe, error) { + var rec Recipe + err := s.Scan( + &rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty, + &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, + &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, + &rec.CarbsPerServing, &rec.FiberPerServing, + &rec.Notes, + &rec.CreatedAt, &rec.UpdatedAt, + ) + if err != nil { + return nil, err + } + rec.Ingredients = []RecipeIngredient{} + rec.Steps = []RecipeStep{} + return &rec, nil +} + +// --- null helpers --- + +func nullableStr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func nullableInt(n int) *int { + if n <= 0 { + return nil + } + return &n +} + +func nullableFloat(f float64) *float64 { + if f <= 0 { + return nil + } + return &f +} diff --git a/backend/internal/ingredient/repository.go b/backend/internal/ingredient/repository.go index 9b1b32d..10ab185 100644 --- a/backend/internal/ingredient/repository.go +++ b/backend/internal/ingredient/repository.go @@ -11,7 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// Repository handles persistence for ingredient_mappings and their translations. +// Repository handles persistence for ingredients and their translations. type Repository struct { pool *pgxpool.Pool } @@ -21,11 +21,11 @@ func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } -// Upsert inserts or updates an ingredient mapping (English canonical content). +// Upsert inserts or updates an ingredient (English canonical content). // Conflict is resolved on canonical_name. func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) { query := ` - INSERT INTO ingredient_mappings ( + INSERT INTO ingredients ( canonical_name, category, default_unit, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, @@ -54,7 +54,7 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi return scanMappingWrite(row) } -// GetByID returns an ingredient mapping by UUID. +// GetByID returns an ingredient by UUID. // CanonicalName and aliases are resolved for the language stored in ctx. // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) { @@ -68,7 +68,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, im.storage_days, im.created_at, im.updated_at, COALESCE(al.aliases, '[]'::json) AS aliases - FROM ingredient_mappings im + FROM ingredients im LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2 LEFT JOIN ingredient_category_translations ict @@ -88,7 +88,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping return m, err } -// FuzzyMatch finds the single best matching ingredient mapping for a given name. +// FuzzyMatch finds the single best matching ingredient for a given name. // Searches both English and translated names for the language in ctx. // Returns nil, nil when no match is found. func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) { @@ -102,7 +102,7 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa return results[0], nil } -// Search finds ingredient mappings matching the query string. +// Search finds ingredients matching the query string. // Searches aliases table and translated names for the language in ctx. func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) { if limit <= 0 { @@ -118,7 +118,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, im.storage_days, im.created_at, im.updated_at, COALESCE(al.aliases, '[]'::json) AS aliases - FROM ingredient_mappings im + FROM ingredients im LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $3 LEFT JOIN ingredient_category_translations ict @@ -142,17 +142,17 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In rows, err := r.pool.Query(ctx, q, query, limit, lang) if err != nil { - return nil, fmt.Errorf("search ingredient_mappings: %w", err) + return nil, fmt.Errorf("search ingredients: %w", err) } defer rows.Close() return collectMappingsRead(rows) } -// Count returns the total number of ingredient mappings. +// Count returns the total number of ingredients. func (r *Repository) Count(ctx context.Context) (int, error) { var n int - if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil { - return 0, fmt.Errorf("count ingredient_mappings: %w", err) + if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil { + return 0, fmt.Errorf("count ingredients: %w", err) } return n, nil } @@ -165,7 +165,7 @@ func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, li im.category, im.default_unit, im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, im.storage_days, im.created_at, im.updated_at - FROM ingredient_mappings im + FROM ingredients im WHERE NOT EXISTS ( SELECT 1 FROM ingredient_translations it WHERE it.ingredient_id = im.id AND it.lang = $3 diff --git a/backend/internal/menu/handler.go b/backend/internal/menu/handler.go index 1a907f9..0957e40 100644 --- a/backend/internal/menu/handler.go +++ b/backend/internal/menu/handler.go @@ -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. diff --git a/backend/internal/menu/repository.go b/backend/internal/menu/repository.go index f9b297f..00e6c77 100644 --- a/backend/internal/menu/repository.go +++ b/backend/internal/menu/repository.go @@ -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 +} diff --git a/backend/internal/product/model.go b/backend/internal/product/model.go index 3d9ed11..5d6b679 100644 --- a/backend/internal/product/model.go +++ b/backend/internal/product/model.go @@ -4,28 +4,30 @@ import "time" // Product is a user's food item in their pantry. type Product struct { - ID string `json:"id"` - UserID string `json:"user_id"` - MappingID *string `json:"mapping_id"` - Name string `json:"name"` - Quantity float64 `json:"quantity"` - Unit string `json:"unit"` - Category *string `json:"category"` - StorageDays int `json:"storage_days"` - AddedAt time.Time `json:"added_at"` - ExpiresAt time.Time `json:"expires_at"` - DaysLeft int `json:"days_left"` - ExpiringSoon bool `json:"expiring_soon"` + ID string `json:"id"` + UserID string `json:"user_id"` + PrimaryIngredientID *string `json:"primary_ingredient_id"` + Name string `json:"name"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + Category *string `json:"category"` + StorageDays int `json:"storage_days"` + AddedAt time.Time `json:"added_at"` + ExpiresAt time.Time `json:"expires_at"` + DaysLeft int `json:"days_left"` + ExpiringSoon bool `json:"expiring_soon"` } // CreateRequest is the body for POST /products. type CreateRequest struct { - MappingID *string `json:"mapping_id"` - Name string `json:"name"` - Quantity float64 `json:"quantity"` - Unit string `json:"unit"` - Category *string `json:"category"` - StorageDays int `json:"storage_days"` + PrimaryIngredientID *string `json:"primary_ingredient_id"` + // Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields. + MappingID *string `json:"mapping_id"` + Name string `json:"name"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + Category *string `json:"category"` + StorageDays int `json:"storage_days"` } // UpdateRequest is the body for PUT /products/{id}. diff --git a/backend/internal/product/repository.go b/backend/internal/product/repository.go index ebce450..79cb586 100644 --- a/backend/internal/product/repository.go +++ b/backend/internal/product/repository.go @@ -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) diff --git a/backend/internal/recipe/handler.go b/backend/internal/recipe/handler.go new file mode 100644 index 0000000..a3aba5f --- /dev/null +++ b/backend/internal/recipe/handler.go @@ -0,0 +1,58 @@ +package recipe + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/food-ai/backend/internal/middleware" + "github.com/go-chi/chi/v5" +) + +// Handler handles HTTP requests for recipes. +type Handler struct { + repo *Repository +} + +// NewHandler creates a new Handler. +func NewHandler(repo *Repository) *Handler { + return &Handler{repo: repo} +} + +// GetByID handles GET /recipes/{id}. +func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + id := chi.URLParam(r, "id") + rec, err := h.repo.GetByID(r.Context(), id) + if err != nil { + slog.Error("get recipe", "id", id, "err", err) + writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe") + return + } + if rec == nil { + writeErrorJSON(w, http.StatusNotFound, "recipe not found") + return + } + writeJSON(w, http.StatusOK, rec) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeErrorJSON(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) +} diff --git a/backend/internal/recipe/model.go b/backend/internal/recipe/model.go index 64a44d6..07139f7 100644 --- a/backend/internal/recipe/model.go +++ b/backend/internal/recipe/model.go @@ -1,28 +1,18 @@ package recipe -import ( - "encoding/json" - "time" -) +import "time" -// Recipe is a recipe record in the database. -// Title, Description, Ingredients, and Steps hold the content for the language -// resolved at query time (English by default, or from recipe_translations when -// a matching row exists for the requested language). +// Recipe is a cooking variant of a Dish in the catalog. +// It links to a Dish for all presentational data (name, image, cuisine, tags). type Recipe struct { - ID string `json:"id"` - Source string `json:"source"` // spoonacular | ai | user - SpoonacularID *int `json:"spoonacular_id"` + ID string `json:"id"` + DishID string `json:"dish_id"` + Source string `json:"source"` // ai | user | spoonacular - Title string `json:"title"` - Description *string `json:"description"` - - Cuisine *string `json:"cuisine"` - Difficulty *string `json:"difficulty"` // easy | medium | hard - PrepTimeMin *int `json:"prep_time_min"` - CookTimeMin *int `json:"cook_time_min"` - Servings *int `json:"servings"` - ImageURL *string `json:"image_url"` + Difficulty *string `json:"difficulty"` + PrepTimeMin *int `json:"prep_time_min"` + CookTimeMin *int `json:"cook_time_min"` + Servings *int `json:"servings"` CaloriesPerServing *float64 `json:"calories_per_serving"` ProteinPerServing *float64 `json:"protein_per_serving"` @@ -30,29 +20,29 @@ type Recipe struct { CarbsPerServing *float64 `json:"carbs_per_serving"` FiberPerServing *float64 `json:"fiber_per_serving"` - Ingredients json.RawMessage `json:"ingredients"` // []RecipeIngredient - Steps json.RawMessage `json:"steps"` // []RecipeStep - Tags json.RawMessage `json:"tags"` // []string + Ingredients []RecipeIngredient `json:"ingredients"` + Steps []RecipeStep `json:"steps"` + Notes *string `json:"notes,omitempty"` - AvgRating float64 `json:"avg_rating"` - ReviewCount int `json:"review_count"` - CreatedBy *string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// RecipeIngredient is a single ingredient in a recipe's JSONB array. +// RecipeIngredient is a single ingredient row from recipe_ingredients. type RecipeIngredient struct { - MappingID *string `json:"mapping_id"` - Name string `json:"name"` - Amount float64 `json:"amount"` - Unit string `json:"unit"` - Optional bool `json:"optional"` + ID string `json:"id"` + IngredientID *string `json:"ingredient_id"` + Name string `json:"name"` + Amount float64 `json:"amount"` + UnitCode *string `json:"unit_code"` + IsOptional bool `json:"is_optional"` + SortOrder int `json:"sort_order"` } -// RecipeStep is a single step in a recipe's JSONB array. +// RecipeStep is a single step row from recipe_steps. type RecipeStep struct { - Number int `json:"number"` + ID string `json:"id"` + StepNumber int `json:"step_number"` Description string `json:"description"` TimerSeconds *int `json:"timer_seconds"` ImageURL *string `json:"image_url"` diff --git a/backend/internal/recipe/repository.go b/backend/internal/recipe/repository.go index 4dd662c..64a6665 100644 --- a/backend/internal/recipe/repository.go +++ b/backend/internal/recipe/repository.go @@ -2,7 +2,6 @@ package recipe import ( "context" - "encoding/json" "errors" "fmt" @@ -11,7 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// Repository handles persistence for recipes and their translations. +// Repository handles persistence for recipes and their relational sub-tables. type Repository struct { pool *pgxpool.Pool } @@ -21,77 +20,39 @@ func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } -// Upsert inserts or updates a recipe (English canonical content only). -// Conflict is resolved on spoonacular_id. -func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) { - query := ` - INSERT INTO recipes ( - source, spoonacular_id, - title, description, - cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, - calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, - ingredients, steps, tags - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) - ON CONFLICT (spoonacular_id) DO UPDATE SET - title = EXCLUDED.title, - description = EXCLUDED.description, - cuisine = EXCLUDED.cuisine, - difficulty = EXCLUDED.difficulty, - prep_time_min = EXCLUDED.prep_time_min, - cook_time_min = EXCLUDED.cook_time_min, - servings = EXCLUDED.servings, - image_url = EXCLUDED.image_url, - calories_per_serving = EXCLUDED.calories_per_serving, - protein_per_serving = EXCLUDED.protein_per_serving, - fat_per_serving = EXCLUDED.fat_per_serving, - carbs_per_serving = EXCLUDED.carbs_per_serving, - fiber_per_serving = EXCLUDED.fiber_per_serving, - ingredients = EXCLUDED.ingredients, - steps = EXCLUDED.steps, - tags = EXCLUDED.tags, - updated_at = now() - RETURNING id, source, spoonacular_id, - title, description, - cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, - calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, - ingredients, steps, tags, - avg_rating, review_count, created_by, created_at, updated_at` - - row := r.pool.QueryRow(ctx, query, - recipe.Source, recipe.SpoonacularID, - recipe.Title, recipe.Description, - recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL, - recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing, - recipe.Ingredients, recipe.Steps, recipe.Tags, - ) - return scanRecipe(row) -} - -// GetByID returns a recipe by UUID, with content resolved for the language -// stored in ctx (falls back to English when no translation exists). +// GetByID returns a recipe with its ingredients and steps. +// Text is resolved for the language stored in ctx (English fallback). // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) { lang := locale.FromContext(ctx) - query := ` - SELECT r.id, r.source, r.spoonacular_id, - COALESCE(rt.title, r.title) AS title, - COALESCE(rt.description, r.description) AS description, - r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url, - r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving, - COALESCE(rt.ingredients, r.ingredients) AS ingredients, - COALESCE(rt.steps, r.steps) AS steps, - r.tags, - r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at + + const q = ` + SELECT r.id, r.dish_id, r.source, r.difficulty, + r.prep_time_min, r.cook_time_min, r.servings, + r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, + r.carbs_per_serving, r.fiber_per_serving, + rt.notes, + r.created_at, r.updated_at FROM recipes r LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2 WHERE r.id = $1` - row := r.pool.QueryRow(ctx, query, id, lang) + row := r.pool.QueryRow(ctx, q, id, lang) rec, err := scanRecipe(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - return rec, err + if err != nil { + return nil, fmt.Errorf("get recipe %s: %w", id, err) + } + + if err := r.loadIngredients(ctx, rec, lang); err != nil { + return nil, err + } + if err := r.loadSteps(ctx, rec, lang); err != nil { + return nil, err + } + return rec, nil } // Count returns the total number of recipes. @@ -103,97 +64,79 @@ func (r *Repository) Count(ctx context.Context) (int, error) { return n, nil } -// ListMissingTranslation returns Spoonacular recipes that have no translation -// for the given language, ordered by review_count DESC. -func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) { - query := ` - SELECT id, source, spoonacular_id, - title, description, - cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, - calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, - ingredients, steps, tags, - avg_rating, review_count, created_by, created_at, updated_at - FROM recipes - WHERE source = 'spoonacular' - AND NOT EXISTS ( - SELECT 1 FROM recipe_translations rt - WHERE rt.recipe_id = recipes.id AND rt.lang = $3 - ) - ORDER BY review_count DESC - LIMIT $1 OFFSET $2` - - rows, err := r.pool.Query(ctx, query, limit, offset, lang) +// loadIngredients fills rec.Ingredients from recipe_ingredients. +func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT ri.id, ri.ingredient_id, + COALESCE(rit.name, ri.name) AS name, + ri.amount, ri.unit_code, ri.is_optional, ri.sort_order + FROM recipe_ingredients ri + LEFT JOIN recipe_ingredient_translations rit + ON rit.ri_id = ri.id AND rit.lang = $2 + WHERE ri.recipe_id = $1 + ORDER BY ri.sort_order`, rec.ID, lang) if err != nil { - return nil, fmt.Errorf("list missing translation (%s): %w", lang, err) + return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err) } defer rows.Close() - return collectRecipes(rows) -} -// UpsertTranslation inserts or replaces a recipe translation for a specific language. -func (r *Repository) UpsertTranslation( - ctx context.Context, - id, lang string, - title, description *string, - ingredients, steps json.RawMessage, -) error { - query := ` - INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (recipe_id, lang) DO UPDATE SET - title = EXCLUDED.title, - description = EXCLUDED.description, - ingredients = EXCLUDED.ingredients, - steps = EXCLUDED.steps` - - if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil { - return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err) + for rows.Next() { + var ing RecipeIngredient + if err := rows.Scan( + &ing.ID, &ing.IngredientID, &ing.Name, + &ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder, + ); err != nil { + return fmt.Errorf("scan ingredient: %w", err) + } + rec.Ingredients = append(rec.Ingredients, ing) } - return nil + return rows.Err() } -// --- helpers --- +// loadSteps fills rec.Steps from recipe_steps. +func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT rs.id, rs.step_number, + COALESCE(rst.description, rs.description) AS description, + rs.timer_seconds, rs.image_url + FROM recipe_steps rs + LEFT JOIN recipe_step_translations rst + ON rst.step_id = rs.id AND rst.lang = $2 + WHERE rs.recipe_id = $1 + ORDER BY rs.step_number`, rec.ID, lang) + if err != nil { + return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err) + } + defer rows.Close() + + for rows.Next() { + var s RecipeStep + if err := rows.Scan( + &s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL, + ); err != nil { + return fmt.Errorf("scan step: %w", err) + } + rec.Steps = append(rec.Steps, s) + } + return rows.Err() +} + +// --- scan helpers --- func scanRecipe(row pgx.Row) (*Recipe, error) { var rec Recipe - var ingredients, steps, tags []byte - err := row.Scan( - &rec.ID, &rec.Source, &rec.SpoonacularID, - &rec.Title, &rec.Description, - &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, - &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, - &ingredients, &steps, &tags, - &rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt, + &rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty, + &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, + &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, + &rec.CarbsPerServing, &rec.FiberPerServing, + &rec.Notes, + &rec.CreatedAt, &rec.UpdatedAt, ) if err != nil { return nil, err } - rec.Ingredients = json.RawMessage(ingredients) - rec.Steps = json.RawMessage(steps) - rec.Tags = json.RawMessage(tags) + rec.Ingredients = []RecipeIngredient{} + rec.Steps = []RecipeStep{} return &rec, nil } - -func collectRecipes(rows pgx.Rows) ([]*Recipe, error) { - var result []*Recipe - for rows.Next() { - var rec Recipe - var ingredients, steps, tags []byte - if err := rows.Scan( - &rec.ID, &rec.Source, &rec.SpoonacularID, - &rec.Title, &rec.Description, - &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, - &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, - &ingredients, &steps, &tags, - &rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt, - ); err != nil { - return nil, fmt.Errorf("scan recipe: %w", err) - } - rec.Ingredients = json.RawMessage(ingredients) - rec.Steps = json.RawMessage(steps) - rec.Tags = json.RawMessage(tags) - result = append(result, &rec) - } - return result, rows.Err() -} diff --git a/backend/internal/recipe/repository_integration_test.go b/backend/internal/recipe/repository_integration_test.go index 3cbc8fa..1ba0cc0 100644 --- a/backend/internal/recipe/repository_integration_test.go +++ b/backend/internal/recipe/repository_integration_test.go @@ -4,132 +4,11 @@ package recipe import ( "context" - "encoding/json" "testing" - "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/testutil" ) -func TestRecipeRepository_Upsert_Insert(t *testing.T) { - pool := testutil.SetupTestDB(t) - repo := NewRepository(pool) - ctx := context.Background() - - id := 10001 - cuisine := "italian" - diff := "easy" - cookTime := 30 - servings := 4 - - rec := &Recipe{ - Source: "spoonacular", - SpoonacularID: &id, - Title: "Pasta Carbonara", - Cuisine: &cuisine, - Difficulty: &diff, - CookTimeMin: &cookTime, - Servings: &servings, - Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`), - Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`), - Tags: json.RawMessage(`["italian"]`), - } - - got, err := repo.Upsert(ctx, rec) - if err != nil { - t.Fatalf("upsert: %v", err) - } - if got.ID == "" { - t.Error("expected non-empty ID") - } - if got.Title != "Pasta Carbonara" { - t.Errorf("title: want Pasta Carbonara, got %s", got.Title) - } - if got.SpoonacularID == nil || *got.SpoonacularID != id { - t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID) - } -} - -func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) { - pool := testutil.SetupTestDB(t) - repo := NewRepository(pool) - ctx := context.Background() - - id := 20001 - cuisine := "mexican" - diff := "medium" - - first := &Recipe{ - Source: "spoonacular", - SpoonacularID: &id, - Title: "Tacos", - Cuisine: &cuisine, - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`[]`), - } - got1, err := repo.Upsert(ctx, first) - if err != nil { - t.Fatalf("first upsert: %v", err) - } - - second := &Recipe{ - Source: "spoonacular", - SpoonacularID: &id, - Title: "Beef Tacos", - Cuisine: &cuisine, - Difficulty: &diff, - Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`[]`), - } - got2, err := repo.Upsert(ctx, second) - if err != nil { - t.Fatalf("second upsert: %v", err) - } - - if got1.ID != got2.ID { - t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID) - } - if got2.Title != "Beef Tacos" { - t.Errorf("title not updated: got %s", got2.Title) - } -} - -func TestRecipeRepository_GetByID_Found(t *testing.T) { - pool := testutil.SetupTestDB(t) - repo := NewRepository(pool) - ctx := context.Background() - - id := 30001 - diff := "easy" - rec := &Recipe{ - Source: "spoonacular", - SpoonacularID: &id, - Title: "Greek Salad", - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`["vegetarian"]`), - } - saved, err := repo.Upsert(ctx, rec) - if err != nil { - t.Fatalf("upsert: %v", err) - } - - got, err := repo.GetByID(ctx, saved.ID) - if err != nil { - t.Fatalf("get by id: %v", err) - } - if got == nil { - t.Fatal("expected non-nil result") - } - if got.Title != "Greek Salad" { - t.Errorf("want Greek Salad, got %s", got.Title) - } -} - func TestRecipeRepository_GetByID_NotFound(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) @@ -144,181 +23,13 @@ func TestRecipeRepository_GetByID_NotFound(t *testing.T) { } } -func TestRecipeRepository_ListMissingTranslation_Pagination(t *testing.T) { +func TestRecipeRepository_Count(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() - diff := "easy" - for i := 0; i < 5; i++ { - spID := 40000 + i - _, err := repo.Upsert(ctx, &Recipe{ - Source: "spoonacular", - SpoonacularID: &spID, - Title: "Recipe " + string(rune('A'+i)), - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`[]`), - }) - if err != nil { - t.Fatalf("upsert recipe %d: %v", i, err) - } - } - - missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0) + _, err := repo.Count(ctx) if err != nil { - t.Fatalf("list missing translation: %v", err) - } - if len(missing) != 3 { - t.Errorf("expected 3 results with limit=3, got %d", len(missing)) - } -} - -func TestRecipeRepository_UpsertTranslation(t *testing.T) { - pool := testutil.SetupTestDB(t) - repo := NewRepository(pool) - ctx := context.Background() - - id := 50001 - diff := "medium" - saved, err := repo.Upsert(ctx, &Recipe{ - Source: "spoonacular", - SpoonacularID: &id, - Title: "Chicken Tikka Masala", - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`), - Tags: json.RawMessage(`[]`), - }) - if err != nil { - t.Fatalf("upsert: %v", err) - } - - titleRu := "Курица Тикка Масала" - descRu := "Классическое индийское блюдо" - stepsRu := json.RawMessage(`[{"number":1,"description":"Разогрейте масло"}]`) - - if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); err != nil { - t.Fatalf("upsert translation: %v", err) - } - - // Retrieve with Russian context — title and steps should be translated. - ruCtx := locale.WithLang(ctx, "ru") - got, err := repo.GetByID(ruCtx, saved.ID) - if err != nil { - t.Fatalf("get by id: %v", err) - } - if got.Title != titleRu { - t.Errorf("expected title=%q, got %q", titleRu, got.Title) - } - if got.Description == nil || *got.Description != descRu { - t.Errorf("expected description=%q, got %v", descRu, got.Description) - } - - var steps []RecipeStep - if err := json.Unmarshal(got.Steps, &steps); err != nil { - t.Fatalf("unmarshal steps: %v", err) - } - if len(steps) == 0 || steps[0].Description != "Разогрейте масло" { - t.Errorf("expected Russian step description, got %v", steps) - } - - // Retrieve with English context — should return original English content. - enCtx := locale.WithLang(ctx, "en") - gotEn, err := repo.GetByID(enCtx, saved.ID) - if err != nil { - t.Fatalf("get by id (en): %v", err) - } - if gotEn.Title != "Chicken Tikka Masala" { - t.Errorf("expected English title, got %q", gotEn.Title) - } -} - -func TestRecipeRepository_ListMissingTranslation_ExcludesTranslated(t *testing.T) { - pool := testutil.SetupTestDB(t) - repo := NewRepository(pool) - ctx := context.Background() - - diff := "easy" - - // Insert untranslated recipes. - for i := 0; i < 3; i++ { - spID := 60000 + i - _, err := repo.Upsert(ctx, &Recipe{ - Source: "spoonacular", - SpoonacularID: &spID, - Title: "Untranslated " + string(rune('A'+i)), - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`[]`), - }) - if err != nil { - t.Fatalf("upsert: %v", err) - } - } - - // Insert one recipe and add a Russian translation. - spID := 60100 - translated, err := repo.Upsert(ctx, &Recipe{ - Source: "spoonacular", - SpoonacularID: &spID, - Title: "Translated Recipe", - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`[]`), - }) - if err != nil { - t.Fatalf("upsert translated: %v", err) - } - titleRu := "Переведённый рецепт" - if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil { - t.Fatalf("upsert translation: %v", err) - } - - missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0) - if err != nil { - t.Fatalf("list missing translation: %v", err) - } - for _, r := range missing { - if r.Title == "Translated Recipe" { - t.Error("translated recipe should not appear in ListMissingTranslation") - } - } - if len(missing) < 3 { - t.Errorf("expected at least 3 missing, got %d", len(missing)) - } -} - -func TestRecipeRepository_GIN_Tags(t *testing.T) { - pool := testutil.SetupTestDB(t) - repo := NewRepository(pool) - ctx := context.Background() - - id := 70001 - diff := "easy" - _, err := repo.Upsert(ctx, &Recipe{ - Source: "spoonacular", - SpoonacularID: &id, - Title: "Veggie Bowl", - Difficulty: &diff, - Ingredients: json.RawMessage(`[]`), - Steps: json.RawMessage(`[]`), - Tags: json.RawMessage(`["vegetarian","gluten-free"]`), - }) - if err != nil { - t.Fatalf("upsert: %v", err) - } - - // GIN index query: tags @> '["vegetarian"]' - var count int - row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id) - if err := row.Scan(&count); err != nil { - t.Fatalf("query: %v", err) - } - if count != 1 { - t.Errorf("expected 1 vegetarian recipe, got %d", count) + t.Fatalf("count: %v", err) } } diff --git a/backend/internal/savedrecipe/handler.go b/backend/internal/savedrecipe/handler.go index 757aa7f..05d47b4 100644 --- a/backend/internal/savedrecipe/handler.go +++ b/backend/internal/savedrecipe/handler.go @@ -36,8 +36,8 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { writeErrorJSON(w, http.StatusBadRequest, "invalid request body") return } - if req.Title == "" { - writeErrorJSON(w, http.StatusBadRequest, "title is required") + if req.Title == "" && req.RecipeID == "" { + writeErrorJSON(w, http.StatusBadRequest, "title or recipe_id is required") return } @@ -64,9 +64,8 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes") return } - if recipes == nil { - recipes = []*SavedRecipe{} + recipes = []*UserSavedRecipe{} } writeJSON(w, http.StatusOK, recipes) } diff --git a/backend/internal/savedrecipe/model.go b/backend/internal/savedrecipe/model.go index 99bffd8..793c878 100644 --- a/backend/internal/savedrecipe/model.go +++ b/backend/internal/savedrecipe/model.go @@ -1,43 +1,76 @@ package savedrecipe -import ( - "encoding/json" - "time" -) +import "time" -// SavedRecipe is a recipe saved by a specific user. -type SavedRecipe struct { - ID string `json:"id"` - UserID string `json:"-"` - Title string `json:"title"` - Description *string `json:"description"` - Cuisine *string `json:"cuisine"` - Difficulty *string `json:"difficulty"` - PrepTimeMin *int `json:"prep_time_min"` - CookTimeMin *int `json:"cook_time_min"` - Servings *int `json:"servings"` - ImageURL *string `json:"image_url"` - Ingredients json.RawMessage `json:"ingredients"` - Steps json.RawMessage `json:"steps"` - Tags json.RawMessage `json:"tags"` - Nutrition json.RawMessage `json:"nutrition_per_serving"` - Source string `json:"source"` - SavedAt time.Time `json:"saved_at"` +// UserSavedRecipe is a user's bookmark referencing a catalog recipe. +// Display fields are populated by joining dishes + recipes. +type UserSavedRecipe struct { + ID string `json:"id"` + UserID string `json:"-"` + RecipeID string `json:"recipe_id"` + SavedAt time.Time `json:"saved_at"` + + // Display data — joined from dishes + recipes. + DishName string `json:"title"` // dish name used as display title + Description *string `json:"description"` + ImageURL *string `json:"image_url"` + CuisineSlug *string `json:"cuisine_slug"` + Tags []string `json:"tags"` + + Difficulty *string `json:"difficulty"` + PrepTimeMin *int `json:"prep_time_min"` + CookTimeMin *int `json:"cook_time_min"` + Servings *int `json:"servings"` + + CaloriesPerServing *float64 `json:"calories_per_serving"` + ProteinPerServing *float64 `json:"protein_per_serving"` + FatPerServing *float64 `json:"fat_per_serving"` + CarbsPerServing *float64 `json:"carbs_per_serving"` + + Ingredients []RecipeIngredient `json:"ingredients"` + Steps []RecipeStep `json:"steps"` +} + +// RecipeIngredient is a single ingredient row. +type RecipeIngredient struct { + Name string `json:"name"` + Amount float64 `json:"amount"` + UnitCode *string `json:"unit_code"` + IsOptional bool `json:"is_optional"` +} + +// RecipeStep is a single step row. +type RecipeStep struct { + StepNumber int `json:"number"` + Description string `json:"description"` + TimerSeconds *int `json:"timer_seconds"` } // SaveRequest is the body for POST /saved-recipes. +// When recipe_id is provided, the existing catalog recipe is bookmarked. +// Otherwise a new dish+recipe is created from the supplied fields. type SaveRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Cuisine string `json:"cuisine"` - Difficulty string `json:"difficulty"` - PrepTimeMin int `json:"prep_time_min"` - CookTimeMin int `json:"cook_time_min"` - Servings int `json:"servings"` - ImageURL string `json:"image_url"` - Ingredients json.RawMessage `json:"ingredients"` - Steps json.RawMessage `json:"steps"` - Tags json.RawMessage `json:"tags"` - Nutrition json.RawMessage `json:"nutrition_per_serving"` - Source string `json:"source"` + RecipeID string `json:"recipe_id"` // optional: bookmark existing recipe + Title string `json:"title"` + Description string `json:"description"` + Cuisine string `json:"cuisine"` + Difficulty string `json:"difficulty"` + PrepTimeMin int `json:"prep_time_min"` + CookTimeMin int `json:"cook_time_min"` + Servings int `json:"servings"` + ImageURL string `json:"image_url"` + // Ingredients / Steps / Tags / Nutrition are JSONB for backward compatibility + // with the recommendation flow that sends the full Gemini response. + Ingredients interface{} `json:"ingredients"` + Steps interface{} `json:"steps"` + Tags interface{} `json:"tags"` + Nutrition interface{} `json:"nutrition_per_serving"` + Source string `json:"source"` } + +// ErrNotFound is returned when a saved recipe does not exist for the given user. +var ErrNotFound = errorString("saved recipe not found") + +type errorString string + +func (e errorString) Error() string { return string(e) } diff --git a/backend/internal/savedrecipe/repository.go b/backend/internal/savedrecipe/repository.go index ef704db..ffa0f82 100644 --- a/backend/internal/savedrecipe/repository.go +++ b/backend/internal/savedrecipe/repository.go @@ -6,132 +6,244 @@ import ( "errors" "fmt" + "github.com/food-ai/backend/internal/dish" "github.com/food-ai/backend/internal/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// ErrNotFound is returned when a saved recipe does not exist for the given user. -var ErrNotFound = errors.New("saved recipe not found") - -// Repository handles persistence for saved recipes and their translations. +// Repository handles persistence for user_saved_recipes. type Repository struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + dishRepo *dish.Repository } // NewRepository creates a new Repository. -func NewRepository(pool *pgxpool.Pool) *Repository { - return &Repository{pool: pool} +func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository { + return &Repository{pool: pool, dishRepo: dishRepo} } -// Save persists a recipe for userID and returns the stored record. -// The canonical content (any language) is stored directly in saved_recipes. -func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) { - const query = ` - INSERT INTO saved_recipes ( - user_id, title, description, cuisine, difficulty, - prep_time_min, cook_time_min, servings, image_url, - ingredients, steps, tags, nutrition, source - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - RETURNING id, user_id, title, description, cuisine, difficulty, - prep_time_min, cook_time_min, servings, image_url, - ingredients, steps, tags, nutrition, source, saved_at` +// Save bookmarks a recipe for the user. +// If req.RecipeID is set, that existing catalog recipe is bookmarked. +// Otherwise a new dish + recipe is created from the supplied fields. +func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) { + recipeID := req.RecipeID - description := nullableStr(req.Description) - cuisine := nullableStr(req.Cuisine) - difficulty := nullableStr(req.Difficulty) - imageURL := nullableStr(req.ImageURL) - prepTime := nullableInt(req.PrepTimeMin) - cookTime := nullableInt(req.CookTimeMin) - servings := nullableInt(req.Servings) + if recipeID == "" { + // Build a dish.CreateRequest from the save body. + cr := dish.CreateRequest{ + Name: req.Title, + Description: req.Description, + CuisineSlug: mapCuisineSlug(req.Cuisine), + ImageURL: req.ImageURL, + Source: req.Source, + Difficulty: req.Difficulty, + PrepTimeMin: req.PrepTimeMin, + CookTimeMin: req.CookTimeMin, + Servings: req.Servings, + } - source := req.Source - if source == "" { - source = "ai" + // Unmarshal ingredients. + if req.Ingredients != nil { + switch v := req.Ingredients.(type) { + case []interface{}: + for i, item := range v { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + ing := dish.IngredientInput{ + Name: strVal(m["name"]), + Amount: floatVal(m["amount"]), + Unit: strVal(m["unit"]), + } + cr.Ingredients = append(cr.Ingredients, ing) + _ = i + } + case json.RawMessage: + var items []dish.IngredientInput + if err := json.Unmarshal(v, &items); err == nil { + cr.Ingredients = items + } + } + } + + // Unmarshal steps. + if req.Steps != nil { + switch v := req.Steps.(type) { + case []interface{}: + for i, item := range v { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + num := int(floatVal(m["number"])) + if num <= 0 { + num = i + 1 + } + step := dish.StepInput{ + Number: num, + Description: strVal(m["description"]), + } + cr.Steps = append(cr.Steps, step) + } + case json.RawMessage: + var items []dish.StepInput + if err := json.Unmarshal(v, &items); err == nil { + cr.Steps = items + } + } + } + + // Unmarshal tags. + if req.Tags != nil { + switch v := req.Tags.(type) { + case []interface{}: + for _, t := range v { + if s, ok := t.(string); ok { + cr.Tags = append(cr.Tags, s) + } + } + case json.RawMessage: + var items []string + if err := json.Unmarshal(v, &items); err == nil { + cr.Tags = items + } + } + } + + // Unmarshal nutrition. + if req.Nutrition != nil { + switch v := req.Nutrition.(type) { + case map[string]interface{}: + cr.Calories = floatVal(v["calories"]) + cr.Protein = floatVal(v["protein_g"]) + cr.Fat = floatVal(v["fat_g"]) + cr.Carbs = floatVal(v["carbs_g"]) + case json.RawMessage: + var nut struct { + Calories float64 `json:"calories"` + Protein float64 `json:"protein_g"` + Fat float64 `json:"fat_g"` + Carbs float64 `json:"carbs_g"` + } + if err := json.Unmarshal(v, &nut); err == nil { + cr.Calories = nut.Calories + cr.Protein = nut.Protein + cr.Fat = nut.Fat + cr.Carbs = nut.Carbs + } + } + } + + var err error + recipeID, err = r.dishRepo.Create(ctx, cr) + if err != nil { + return nil, fmt.Errorf("create dish+recipe: %w", err) + } } - ingredients := defaultJSONArray(req.Ingredients) - steps := defaultJSONArray(req.Steps) - tags := defaultJSONArray(req.Tags) + // Insert bookmark. + const q = ` + INSERT INTO user_saved_recipes (user_id, recipe_id) + VALUES ($1, $2) + ON CONFLICT (user_id, recipe_id) DO UPDATE SET saved_at = now() + RETURNING id, user_id, recipe_id, saved_at` - row := r.pool.QueryRow(ctx, query, - userID, req.Title, description, cuisine, difficulty, - prepTime, cookTime, servings, imageURL, - ingredients, steps, tags, req.Nutrition, source, + var usr UserSavedRecipe + err := r.pool.QueryRow(ctx, q, userID, recipeID).Scan( + &usr.ID, &usr.UserID, &usr.RecipeID, &usr.SavedAt, ) - return scanRow(row) + if err != nil { + return nil, fmt.Errorf("insert bookmark: %w", err) + } + + return r.enrichOne(ctx, &usr) } -// List returns all saved recipes for userID ordered by saved_at DESC. -// Text content (title, description, ingredients, steps) is resolved for the -// language stored in ctx, falling back to the canonical content when no -// translation exists. -func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) { +// List returns all bookmarked recipes for userID ordered by saved_at DESC. +func (r *Repository) List(ctx context.Context, userID string) ([]*UserSavedRecipe, error) { lang := locale.FromContext(ctx) - const query = ` - SELECT sr.id, sr.user_id, - COALESCE(srt.title, sr.title) AS title, - COALESCE(srt.description, sr.description) AS description, - sr.cuisine, sr.difficulty, - sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url, - COALESCE(srt.ingredients, sr.ingredients) AS ingredients, - COALESCE(srt.steps, sr.steps) AS steps, - sr.tags, sr.nutrition, sr.source, sr.saved_at - FROM saved_recipes sr - LEFT JOIN saved_recipe_translations srt - ON srt.saved_recipe_id = sr.id AND srt.lang = $2 - WHERE sr.user_id = $1 - ORDER BY sr.saved_at DESC` + const q = ` + SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at, + COALESCE(dt.name, d.name) AS dish_name, + COALESCE(dt.description, d.description) AS description, + d.image_url, d.cuisine_slug, + r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, + r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving + FROM user_saved_recipes usr + JOIN recipes r ON r.id = usr.recipe_id + JOIN dishes d ON d.id = r.dish_id + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 + WHERE usr.user_id = $1 + ORDER BY usr.saved_at DESC` - rows, err := r.pool.Query(ctx, query, userID, lang) + rows, err := r.pool.Query(ctx, q, userID, lang) if err != nil { return nil, fmt.Errorf("list saved recipes: %w", err) } defer rows.Close() - var result []*SavedRecipe + var result []*UserSavedRecipe for rows.Next() { - rec, err := scanRow(rows) + rec, err := scanUSR(rows) if err != nil { return nil, fmt.Errorf("scan saved recipe: %w", err) } + if err := r.loadTags(ctx, rec); err != nil { + return nil, err + } + if err := r.loadIngredients(ctx, rec, lang); err != nil { + return nil, err + } + if err := r.loadSteps(ctx, rec, lang); err != nil { + return nil, err + } result = append(result, rec) } return result, rows.Err() } -// GetByID returns the saved recipe with id for userID, or nil if not found. -// Text content is resolved for the language stored in ctx. -func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) { +// GetByID returns a bookmarked recipe by its bookmark ID for userID. +// Returns nil, nil if not found. +func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) { lang := locale.FromContext(ctx) - const query = ` - SELECT sr.id, sr.user_id, - COALESCE(srt.title, sr.title) AS title, - COALESCE(srt.description, sr.description) AS description, - sr.cuisine, sr.difficulty, - sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url, - COALESCE(srt.ingredients, sr.ingredients) AS ingredients, - COALESCE(srt.steps, sr.steps) AS steps, - sr.tags, sr.nutrition, sr.source, sr.saved_at - FROM saved_recipes sr - LEFT JOIN saved_recipe_translations srt - ON srt.saved_recipe_id = sr.id AND srt.lang = $3 - WHERE sr.id = $1 AND sr.user_id = $2` + const q = ` + SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at, + COALESCE(dt.name, d.name) AS dish_name, + COALESCE(dt.description, d.description) AS description, + d.image_url, d.cuisine_slug, + r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, + r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving + FROM user_saved_recipes usr + JOIN recipes r ON r.id = usr.recipe_id + JOIN dishes d ON d.id = r.dish_id + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 + WHERE usr.id = $1 AND usr.user_id = $2` - rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID, lang)) + rec, err := scanUSR(r.pool.QueryRow(ctx, q, id, userID, lang)) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - return rec, err + if err != nil { + return nil, err + } + if err := r.loadTags(ctx, rec); err != nil { + return nil, err + } + if err := r.loadIngredients(ctx, rec, lang); err != nil { + return nil, err + } + if err := r.loadSteps(ctx, rec, lang); err != nil { + return nil, err + } + return rec, nil } -// Delete removes the saved recipe with id for userID. -// Returns ErrNotFound if the record does not exist. +// Delete removes a bookmark. func (r *Repository) Delete(ctx context.Context, userID, id string) error { tag, err := r.pool.Exec(ctx, - `DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`, - id, userID, - ) + `DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID) if err != nil { return fmt.Errorf("delete saved recipe: %w", err) } @@ -141,73 +253,170 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error { return nil } -// UpsertTranslation inserts or replaces a translation for a saved recipe. -func (r *Repository) UpsertTranslation( - ctx context.Context, - id, lang string, - title, description *string, - ingredients, steps json.RawMessage, -) error { - const query = ` - INSERT INTO saved_recipe_translations (saved_recipe_id, lang, title, description, ingredients, steps) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (saved_recipe_id, lang) DO UPDATE SET - title = EXCLUDED.title, - description = EXCLUDED.description, - ingredients = EXCLUDED.ingredients, - steps = EXCLUDED.steps, - generated_at = now()` - - if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil { - return fmt.Errorf("upsert saved recipe translation %s/%s: %w", id, lang, err) - } - return nil -} - // --- helpers --- -type scannable interface { +func (r *Repository) enrichOne(ctx context.Context, usr *UserSavedRecipe) (*UserSavedRecipe, error) { + lang := locale.FromContext(ctx) + const q = ` + SELECT COALESCE(dt.name, d.name) AS dish_name, + COALESCE(dt.description, d.description) AS description, + d.image_url, d.cuisine_slug, + rec.difficulty, rec.prep_time_min, rec.cook_time_min, rec.servings, + rec.calories_per_serving, rec.protein_per_serving, + rec.fat_per_serving, rec.carbs_per_serving + FROM recipes rec + JOIN dishes d ON d.id = rec.dish_id + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 + WHERE rec.id = $1` + + row := r.pool.QueryRow(ctx, q, usr.RecipeID, lang) + if err := row.Scan( + &usr.DishName, &usr.Description, &usr.ImageURL, &usr.CuisineSlug, + &usr.Difficulty, &usr.PrepTimeMin, &usr.CookTimeMin, &usr.Servings, + &usr.CaloriesPerServing, &usr.ProteinPerServing, &usr.FatPerServing, &usr.CarbsPerServing, + ); err != nil { + return nil, fmt.Errorf("enrich saved recipe: %w", err) + } + if err := r.loadTags(ctx, usr); err != nil { + return nil, err + } + if err := r.loadIngredients(ctx, usr, lang); err != nil { + return nil, err + } + return usr, r.loadSteps(ctx, usr, lang) +} + +func (r *Repository) loadTags(ctx context.Context, usr *UserSavedRecipe) error { + rows, err := r.pool.Query(ctx, ` + SELECT dt.tag_slug + FROM dish_tags dt + JOIN recipes rec ON rec.dish_id = dt.dish_id + JOIN user_saved_recipes usr ON usr.recipe_id = rec.id + WHERE usr.id = $1 + ORDER BY dt.tag_slug`, usr.ID) + if err != nil { + return fmt.Errorf("load tags for saved recipe %s: %w", usr.ID, err) + } + defer rows.Close() + usr.Tags = []string{} + for rows.Next() { + var slug string + if err := rows.Scan(&slug); err != nil { + return err + } + usr.Tags = append(usr.Tags, slug) + } + return rows.Err() +} + +func (r *Repository) loadIngredients(ctx context.Context, usr *UserSavedRecipe, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT COALESCE(rit.name, ri.name) AS name, + ri.amount, ri.unit_code, ri.is_optional + FROM recipe_ingredients ri + LEFT JOIN recipe_ingredient_translations rit + ON rit.ri_id = ri.id AND rit.lang = $2 + WHERE ri.recipe_id = $1 + ORDER BY ri.sort_order`, usr.RecipeID, lang) + if err != nil { + return fmt.Errorf("load ingredients for saved recipe %s: %w", usr.ID, err) + } + defer rows.Close() + usr.Ingredients = []RecipeIngredient{} + for rows.Next() { + var ing RecipeIngredient + if err := rows.Scan(&ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional); err != nil { + return err + } + usr.Ingredients = append(usr.Ingredients, ing) + } + return rows.Err() +} + +func (r *Repository) loadSteps(ctx context.Context, usr *UserSavedRecipe, lang string) error { + rows, err := r.pool.Query(ctx, ` + SELECT rs.step_number, + COALESCE(rst.description, rs.description) AS description, + rs.timer_seconds + FROM recipe_steps rs + LEFT JOIN recipe_step_translations rst + ON rst.step_id = rs.id AND rst.lang = $2 + WHERE rs.recipe_id = $1 + ORDER BY rs.step_number`, usr.RecipeID, lang) + if err != nil { + return fmt.Errorf("load steps for saved recipe %s: %w", usr.ID, err) + } + defer rows.Close() + usr.Steps = []RecipeStep{} + for rows.Next() { + var s RecipeStep + if err := rows.Scan(&s.StepNumber, &s.Description, &s.TimerSeconds); err != nil { + return err + } + usr.Steps = append(usr.Steps, s) + } + return rows.Err() +} + +type rowScanner interface { Scan(dest ...any) error } -func scanRow(s scannable) (*SavedRecipe, error) { - var rec SavedRecipe - var ingredients, steps, tags, nutrition []byte +func scanUSR(s rowScanner) (*UserSavedRecipe, error) { + var r UserSavedRecipe err := s.Scan( - &rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty, - &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, - &ingredients, &steps, &tags, &nutrition, - &rec.Source, &rec.SavedAt, + &r.ID, &r.UserID, &r.RecipeID, &r.SavedAt, + &r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug, + &r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings, + &r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing, ) - if err != nil { - return nil, err - } - rec.Ingredients = json.RawMessage(ingredients) - rec.Steps = json.RawMessage(steps) - rec.Tags = json.RawMessage(tags) - if len(nutrition) > 0 { - rec.Nutrition = json.RawMessage(nutrition) - } - return &rec, nil + return &r, err } -func nullableStr(s string) *string { - if s == "" { - return nil +// 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", } - return &s + if s, ok := known[cuisine]; ok { + return s + } + return "other" } -func nullableInt(n int) *int { - if n <= 0 { - return nil +func strVal(v interface{}) string { + if s, ok := v.(string); ok { + return s } - return &n + return "" } -func defaultJSONArray(raw json.RawMessage) json.RawMessage { - if len(raw) == 0 { - return json.RawMessage(`[]`) +func floatVal(v interface{}) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) } - return raw + return 0 } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 8f91914..7820a0e 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -5,12 +5,16 @@ import ( "net/http" "github.com/food-ai/backend/internal/auth" + "github.com/food-ai/backend/internal/cuisine" "github.com/food-ai/backend/internal/diary" + "github.com/food-ai/backend/internal/dish" "github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/language" "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/recipe" + "github.com/food-ai/backend/internal/tag" "github.com/food-ai/backend/internal/units" "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recognition" @@ -33,6 +37,8 @@ func NewRouter( menuHandler *menu.Handler, diaryHandler *diary.Handler, homeHandler *home.Handler, + dishHandler *dish.Handler, + recipeHandler *recipe.Handler, authMiddleware func(http.Handler) http.Handler, allowedOrigins []string, ) *chi.Mux { @@ -49,6 +55,8 @@ func NewRouter( r.Get("/health", healthCheck(pool)) r.Get("/languages", language.List) r.Get("/units", units.List) + r.Get("/cuisines", cuisine.List) + r.Get("/tags", tag.List) r.Route("/auth", func(r chi.Router) { r.Post("/login", authHandler.Login) r.Post("/refresh", authHandler.Refresh) @@ -81,6 +89,13 @@ func NewRouter( r.Delete("/{id}", productHandler.Delete) }) + r.Route("/dishes", func(r chi.Router) { + r.Get("/", dishHandler.List) + r.Get("/{id}", dishHandler.GetByID) + }) + + r.Get("/recipes/{id}", recipeHandler.GetByID) + r.Route("/menu", func(r chi.Router) { r.Get("/", menuHandler.GetMenu) r.Put("/items/{id}", menuHandler.UpdateMenuItem) diff --git a/backend/internal/tag/handler.go b/backend/internal/tag/handler.go new file mode 100644 index 0000000..5e4ab43 --- /dev/null +++ b/backend/internal/tag/handler.go @@ -0,0 +1,28 @@ +package tag + +import ( + "encoding/json" + "net/http" + + "github.com/food-ai/backend/internal/locale" +) + +type tagItem struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// List handles GET /tags — returns tags with names in the requested language. +func List(w http.ResponseWriter, r *http.Request) { + lang := locale.FromContext(r.Context()) + items := make([]tagItem, 0, len(Records)) + for _, t := range Records { + name, ok := t.Translations[lang] + if !ok { + name = t.Name + } + items = append(items, tagItem{Slug: t.Slug, Name: name}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"tags": items}) +} diff --git a/backend/internal/tag/registry.go b/backend/internal/tag/registry.go new file mode 100644 index 0000000..affbf5a --- /dev/null +++ b/backend/internal/tag/registry.go @@ -0,0 +1,79 @@ +package tag + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Record is a tag loaded from DB with all its translations. +type Record struct { + Slug string + Name string // English canonical name + SortOrder int + Translations map[string]string // lang → localized name +} + +// Records is the ordered list of tags, populated by LoadFromDB at startup. +var Records []Record + +// LoadFromDB queries tags + tag_translations and populates Records. +func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error { + rows, err := pool.Query(ctx, ` + SELECT t.slug, t.name, t.sort_order, tt.lang, tt.name + FROM tags t + LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug + ORDER BY t.sort_order, tt.lang`) + if err != nil { + return fmt.Errorf("load tags from db: %w", err) + } + defer rows.Close() + + bySlug := map[string]*Record{} + var order []string + for rows.Next() { + var slug, engName string + var sortOrder int + var lang, name *string + if err := rows.Scan(&slug, &engName, &sortOrder, &lang, &name); err != nil { + return err + } + if _, ok := bySlug[slug]; !ok { + bySlug[slug] = &Record{ + Slug: slug, + Name: engName, + SortOrder: sortOrder, + Translations: map[string]string{}, + } + order = append(order, slug) + } + if lang != nil && name != nil { + bySlug[slug].Translations[*lang] = *name + } + } + if err := rows.Err(); err != nil { + return err + } + + result := make([]Record, 0, len(order)) + for _, slug := range order { + result = append(result, *bySlug[slug]) + } + Records = result + return nil +} + +// NameFor returns the localized name for a tag slug. +// Falls back to the English canonical name. +func NameFor(slug, lang string) string { + for _, t := range Records { + if t.Slug == slug { + if name, ok := t.Translations[lang]; ok { + return name + } + return t.Name + } + } + return slug +} diff --git a/backend/migrations/001_create_users.sql b/backend/migrations/001_create_users.sql deleted file mode 100644 index 68329a2..0000000 --- a/backend/migrations/001_create_users.sql +++ /dev/null @@ -1,80 +0,0 @@ --- +goose Up - --- Generate UUID v7 (time-ordered, millisecond precision). --- Structure: 48-bit unix_ts_ms | 4-bit version (0111) | 12-bit rand_a | 2-bit variant (10) | 62-bit rand_b -CREATE OR REPLACE FUNCTION uuid_generate_v7() -RETURNS uuid -AS $$ -DECLARE - unix_ts_ms bytea; - uuid_bytes bytea; -BEGIN - -- 48-bit Unix timestamp in milliseconds. - -- int8send produces 8 big-endian bytes; skip the first 2 zero bytes to get 6. - unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3); - - -- Use a random v4 UUID as the source of random bits for rand_a and rand_b. - uuid_bytes = uuid_send(gen_random_uuid()); - - -- Overwrite bytes 0–5 with the timestamp (positions 1–6 in 1-indexed bytea). - uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6); - - -- Set version nibble (bits 48–51) to 0111 (7). - uuid_bytes = set_bit(uuid_bytes, 48, 0); - uuid_bytes = set_bit(uuid_bytes, 49, 1); - uuid_bytes = set_bit(uuid_bytes, 50, 1); - uuid_bytes = set_bit(uuid_bytes, 51, 1); - - -- Variant bits (64–65) stay at 10 as inherited from gen_random_uuid(). - - RETURN encode(uuid_bytes, 'hex')::uuid; -END -$$ LANGUAGE plpgsql VOLATILE; - -CREATE TYPE user_plan AS ENUM ('free', 'paid'); -CREATE TYPE user_gender AS ENUM ('male', 'female'); -CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); -CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); - -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - firebase_uid VARCHAR(128) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL DEFAULT '', - avatar_url TEXT, - - -- Body parameters - height_cm SMALLINT, - weight_kg DECIMAL(5,2), - age SMALLINT, - gender user_gender, - activity activity_level, - - -- Goal and calculated daily norm - goal user_goal, - daily_calories INTEGER, - - -- Plan - plan user_plan NOT NULL DEFAULT 'free', - - -- Preferences (JSONB for flexibility) - preferences JSONB NOT NULL DEFAULT '{}'::jsonb, - - -- Refresh token - refresh_token TEXT, - token_expires_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_users_firebase_uid ON users (firebase_uid); -CREATE INDEX idx_users_email ON users (email); - --- +goose Down -DROP TABLE IF EXISTS users; -DROP TYPE IF EXISTS activity_level; -DROP TYPE IF EXISTS user_goal; -DROP TYPE IF EXISTS user_gender; -DROP TYPE IF EXISTS user_plan; -DROP FUNCTION IF EXISTS uuid_generate_v7(); diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql new file mode 100644 index 0000000..9468485 --- /dev/null +++ b/backend/migrations/001_initial_schema.sql @@ -0,0 +1,452 @@ +-- +goose Up + +-- --------------------------------------------------------------------------- +-- Extensions +-- --------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- --------------------------------------------------------------------------- +-- UUID v7 generator (time-ordered, millisecond precision). +-- Structure: 48-bit unix_ts_ms | 4-bit version (0111) | 12-bit rand_a | 2-bit variant (10) | 62-bit rand_b +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION uuid_generate_v7() +RETURNS uuid +AS $$ +DECLARE + unix_ts_ms bytea; + uuid_bytes bytea; +BEGIN + -- 48-bit Unix timestamp in milliseconds. + -- int8send produces 8 big-endian bytes; skip the first 2 zero bytes to get 6. + unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3); + + -- Use a random v4 UUID as the source of random bits for rand_a and rand_b. + uuid_bytes = uuid_send(gen_random_uuid()); + + -- Overwrite bytes 0-5 with the timestamp (positions 1-6 in 1-indexed bytea). + uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6); + + -- Set version nibble (bits 48-51) to 0111 (7). + uuid_bytes = set_bit(uuid_bytes, 48, 0); + uuid_bytes = set_bit(uuid_bytes, 49, 1); + uuid_bytes = set_bit(uuid_bytes, 50, 1); + uuid_bytes = set_bit(uuid_bytes, 51, 1); + + -- Variant bits (64-65) stay at 10 as inherited from gen_random_uuid(). + + RETURN encode(uuid_bytes, 'hex')::uuid; +END +$$ LANGUAGE plpgsql VOLATILE; + +-- --------------------------------------------------------------------------- +-- Enums +-- --------------------------------------------------------------------------- +CREATE TYPE user_plan AS ENUM ('free', 'paid'); +CREATE TYPE user_gender AS ENUM ('male', 'female'); +CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); +CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); +CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user'); +CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard'); + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + firebase_uid VARCHAR(128) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + avatar_url TEXT, + height_cm SMALLINT, + weight_kg DECIMAL(5,2), + date_of_birth DATE, + gender user_gender, + activity activity_level, + goal user_goal, + daily_calories INTEGER, + plan user_plan NOT NULL DEFAULT 'free', + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_users_firebase_uid ON users (firebase_uid); +CREATE INDEX idx_users_email ON users (email); + +-- --------------------------------------------------------------------------- +-- languages +-- --------------------------------------------------------------------------- +CREATE TABLE languages ( + code VARCHAR(10) PRIMARY KEY, + native_name TEXT NOT NULL, + english_name TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order SMALLINT NOT NULL DEFAULT 0 +); + +-- --------------------------------------------------------------------------- +-- units + unit_translations +-- --------------------------------------------------------------------------- +CREATE TABLE units ( + code VARCHAR(20) PRIMARY KEY, + sort_order SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE unit_translations ( + unit_code VARCHAR(20) NOT NULL REFERENCES units(code) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (unit_code, lang) +); + +-- --------------------------------------------------------------------------- +-- ingredient_categories + translations +-- --------------------------------------------------------------------------- +CREATE TABLE ingredient_categories ( + slug VARCHAR(50) PRIMARY KEY, + sort_order SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE ingredient_category_translations ( + category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (category_slug, lang) +); + +-- --------------------------------------------------------------------------- +-- ingredients (canonical catalog — formerly ingredient_mappings) +-- --------------------------------------------------------------------------- +CREATE TABLE ingredients ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + canonical_name VARCHAR(255) NOT NULL, + category VARCHAR(50) REFERENCES ingredient_categories(slug), + default_unit VARCHAR(20) REFERENCES units(code), + calories_per_100g DECIMAL(8,2), + protein_per_100g DECIMAL(8,2), + fat_per_100g DECIMAL(8,2), + carbs_per_100g DECIMAL(8,2), + fiber_per_100g DECIMAL(8,2), + storage_days INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name) +); +CREATE INDEX idx_ingredients_canonical_name ON ingredients (canonical_name); +CREATE INDEX idx_ingredients_category ON ingredients (category); + +-- --------------------------------------------------------------------------- +-- ingredient_translations +-- --------------------------------------------------------------------------- +CREATE TABLE ingredient_translations ( + ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name VARCHAR(255) NOT NULL, + PRIMARY KEY (ingredient_id, lang) +); +CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id); + +-- --------------------------------------------------------------------------- +-- ingredient_aliases (relational, replaces JSONB aliases column) +-- --------------------------------------------------------------------------- +CREATE TABLE ingredient_aliases ( + ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + alias TEXT NOT NULL, + PRIMARY KEY (ingredient_id, lang, alias) +); +CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang); +CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops); + +-- --------------------------------------------------------------------------- +-- cuisines + cuisine_translations +-- --------------------------------------------------------------------------- +CREATE TABLE cuisines ( + slug VARCHAR(50) PRIMARY KEY, + name TEXT NOT NULL, + sort_order SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE cuisine_translations ( + cuisine_slug VARCHAR(50) NOT NULL REFERENCES cuisines(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (cuisine_slug, lang) +); + +-- --------------------------------------------------------------------------- +-- tags + tag_translations +-- --------------------------------------------------------------------------- +CREATE TABLE tags ( + slug VARCHAR(100) PRIMARY KEY, + name TEXT NOT NULL, + sort_order SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE tag_translations ( + tag_slug VARCHAR(100) NOT NULL REFERENCES tags(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (tag_slug, lang) +); + +-- --------------------------------------------------------------------------- +-- dish_categories + dish_category_translations +-- --------------------------------------------------------------------------- +CREATE TABLE dish_categories ( + slug VARCHAR(50) PRIMARY KEY, + name TEXT NOT NULL, + sort_order SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE dish_category_translations ( + category_slug VARCHAR(50) NOT NULL REFERENCES dish_categories(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (category_slug, lang) +); + +-- --------------------------------------------------------------------------- +-- dishes + dish_translations + dish_tags +-- --------------------------------------------------------------------------- +CREATE TABLE dishes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + cuisine_slug VARCHAR(50) REFERENCES cuisines(slug) ON DELETE SET NULL, + category_slug VARCHAR(50) REFERENCES dish_categories(slug) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + image_url TEXT, + avg_rating DECIMAL(3,2) NOT NULL DEFAULT 0.0, + review_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_dishes_cuisine ON dishes (cuisine_slug); +CREATE INDEX idx_dishes_category ON dishes (category_slug); +CREATE INDEX idx_dishes_rating ON dishes (avg_rating DESC); + +CREATE TABLE dish_translations ( + dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + description TEXT, + PRIMARY KEY (dish_id, lang) +); + +CREATE TABLE dish_tags ( + dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE, + tag_slug VARCHAR(100) NOT NULL REFERENCES tags(slug) ON DELETE CASCADE, + PRIMARY KEY (dish_id, tag_slug) +); + +-- --------------------------------------------------------------------------- +-- recipes (one cooking variant of a dish) +-- --------------------------------------------------------------------------- +CREATE TABLE recipes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE, + source recipe_source NOT NULL DEFAULT 'ai', + difficulty recipe_difficulty, + prep_time_min INTEGER, + cook_time_min INTEGER, + servings SMALLINT, + calories_per_serving DECIMAL(8,2), + protein_per_serving DECIMAL(8,2), + fat_per_serving DECIMAL(8,2), + carbs_per_serving DECIMAL(8,2), + fiber_per_serving DECIMAL(8,2), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_recipes_dish_id ON recipes (dish_id); +CREATE INDEX idx_recipes_difficulty ON recipes (difficulty); +CREATE INDEX idx_recipes_prep_time ON recipes (prep_time_min); +CREATE INDEX idx_recipes_calories ON recipes (calories_per_serving); +CREATE INDEX idx_recipes_source ON recipes (source); + +-- --------------------------------------------------------------------------- +-- recipe_translations (per-language cooking notes only) +-- --------------------------------------------------------------------------- +CREATE TABLE recipe_translations ( + recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + notes TEXT, + PRIMARY KEY (recipe_id, lang) +); + +-- --------------------------------------------------------------------------- +-- recipe_ingredients + recipe_ingredient_translations +-- --------------------------------------------------------------------------- +CREATE TABLE recipe_ingredients ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + ingredient_id UUID REFERENCES ingredients(id) ON DELETE SET NULL, + name TEXT NOT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + unit_code VARCHAR(20), + is_optional BOOLEAN NOT NULL DEFAULT false, + sort_order SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX idx_recipe_ingredients_recipe_id ON recipe_ingredients (recipe_id); + +CREATE TABLE recipe_ingredient_translations ( + ri_id UUID NOT NULL REFERENCES recipe_ingredients(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (ri_id, lang) +); + +-- --------------------------------------------------------------------------- +-- recipe_steps + recipe_step_translations +-- --------------------------------------------------------------------------- +CREATE TABLE recipe_steps ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + step_number SMALLINT NOT NULL, + timer_seconds INTEGER, + image_url TEXT, + description TEXT NOT NULL, + UNIQUE (recipe_id, step_number) +); +CREATE INDEX idx_recipe_steps_recipe_id ON recipe_steps (recipe_id); + +CREATE TABLE recipe_step_translations ( + step_id UUID NOT NULL REFERENCES recipe_steps(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + description TEXT NOT NULL, + PRIMARY KEY (step_id, lang) +); + +-- --------------------------------------------------------------------------- +-- products (user fridge / pantry items) +-- --------------------------------------------------------------------------- +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + primary_ingredient_id UUID REFERENCES ingredients(id), + name TEXT NOT NULL, + quantity DECIMAL(10,2) NOT NULL DEFAULT 1, + unit TEXT NOT NULL DEFAULT 'pcs' REFERENCES units(code), + category TEXT, + storage_days INT NOT NULL DEFAULT 7, + added_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_products_user_id ON products (user_id); + +-- --------------------------------------------------------------------------- +-- product_ingredients (M2M: composite product ↔ ingredients) +-- --------------------------------------------------------------------------- +CREATE TABLE product_ingredients ( + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + amount_per_100g DECIMAL(10,2), + PRIMARY KEY (product_id, ingredient_id) +); + +-- --------------------------------------------------------------------------- +-- user_saved_recipes (thin bookmark — content lives in dishes + recipes) +-- --------------------------------------------------------------------------- +CREATE TABLE user_saved_recipes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + saved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, recipe_id) +); +CREATE INDEX idx_user_saved_recipes_user_id ON user_saved_recipes (user_id, saved_at DESC); + +-- --------------------------------------------------------------------------- +-- menu_plans + menu_items + shopping_lists +-- --------------------------------------------------------------------------- +CREATE TABLE menu_plans ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week_start DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, week_start) +); + +CREATE TABLE menu_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE, + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')), + recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, + dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL, + recipe_data JSONB, + UNIQUE (menu_plan_id, day_of_week, meal_type) +); + +CREATE TABLE shopping_lists ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + menu_plan_id UUID REFERENCES menu_plans(id) ON DELETE CASCADE, + items JSONB NOT NULL DEFAULT '[]', + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, menu_plan_id) +); + +-- --------------------------------------------------------------------------- +-- meal_diary +-- --------------------------------------------------------------------------- +CREATE TABLE meal_diary ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date DATE NOT NULL, + meal_type TEXT NOT NULL, + name TEXT NOT NULL, + portions DECIMAL(5,2) NOT NULL DEFAULT 1, + calories DECIMAL(8,2), + protein_g DECIMAL(8,2), + fat_g DECIMAL(8,2), + carbs_g DECIMAL(8,2), + source TEXT NOT NULL DEFAULT 'manual', + dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL, + recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, + portion_g DECIMAL(10,2), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date); + +-- +goose Down +DROP TABLE IF EXISTS meal_diary; +DROP TABLE IF EXISTS shopping_lists; +DROP TABLE IF EXISTS menu_items; +DROP TABLE IF EXISTS menu_plans; +DROP TABLE IF EXISTS user_saved_recipes; +DROP TABLE IF EXISTS product_ingredients; +DROP TABLE IF EXISTS products; +DROP TABLE IF EXISTS recipe_step_translations; +DROP TABLE IF EXISTS recipe_steps; +DROP TABLE IF EXISTS recipe_ingredient_translations; +DROP TABLE IF EXISTS recipe_ingredients; +DROP TABLE IF EXISTS recipe_translations; +DROP TABLE IF EXISTS recipes; +DROP TABLE IF EXISTS dish_tags; +DROP TABLE IF EXISTS dish_translations; +DROP TABLE IF EXISTS dishes; +DROP TABLE IF EXISTS dish_category_translations; +DROP TABLE IF EXISTS dish_categories; +DROP TABLE IF EXISTS tag_translations; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS cuisine_translations; +DROP TABLE IF EXISTS cuisines; +DROP TABLE IF EXISTS ingredient_aliases; +DROP TABLE IF EXISTS ingredient_translations; +DROP TABLE IF EXISTS ingredients; +DROP TABLE IF EXISTS ingredient_category_translations; +DROP TABLE IF EXISTS ingredient_categories; +DROP TABLE IF EXISTS unit_translations; +DROP TABLE IF EXISTS units; +DROP TABLE IF EXISTS languages; +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS recipe_difficulty; +DROP TYPE IF EXISTS recipe_source; +DROP TYPE IF EXISTS activity_level; +DROP TYPE IF EXISTS user_goal; +DROP TYPE IF EXISTS user_gender; +DROP TYPE IF EXISTS user_plan; +DROP FUNCTION IF EXISTS uuid_generate_v7(); +DROP EXTENSION IF EXISTS pg_trgm; diff --git a/backend/migrations/002_create_ingredient_mappings.sql b/backend/migrations/002_create_ingredient_mappings.sql deleted file mode 100644 index 0248ebf..0000000 --- a/backend/migrations/002_create_ingredient_mappings.sql +++ /dev/null @@ -1,36 +0,0 @@ --- +goose Up -CREATE TABLE ingredient_mappings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - canonical_name VARCHAR(255) NOT NULL, - canonical_name_ru VARCHAR(255), - spoonacular_id INTEGER UNIQUE, - - aliases JSONB NOT NULL DEFAULT '[]'::jsonb, - - category VARCHAR(50), - default_unit VARCHAR(20), - - -- Nutrients per 100g - calories_per_100g DECIMAL(8, 2), - protein_per_100g DECIMAL(8, 2), - fat_per_100g DECIMAL(8, 2), - carbs_per_100g DECIMAL(8, 2), - fiber_per_100g DECIMAL(8, 2), - - storage_days INTEGER, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_ingredient_mappings_aliases - ON ingredient_mappings USING GIN (aliases); - -CREATE INDEX idx_ingredient_mappings_canonical_name - ON ingredient_mappings (canonical_name); - -CREATE INDEX idx_ingredient_mappings_category - ON ingredient_mappings (category); - --- +goose Down -DROP TABLE IF EXISTS ingredient_mappings; diff --git a/backend/migrations/002_seed_data.sql b/backend/migrations/002_seed_data.sql new file mode 100644 index 0000000..172edef --- /dev/null +++ b/backend/migrations/002_seed_data.sql @@ -0,0 +1,190 @@ +-- +goose Up + +-- --------------------------------------------------------------------------- +-- languages +-- --------------------------------------------------------------------------- +INSERT INTO languages (code, native_name, english_name, sort_order) VALUES + ('en', 'English', 'English', 1), + ('ru', 'Русский', 'Russian', 2), + ('es', 'Español', 'Spanish', 3), + ('de', 'Deutsch', 'German', 4), + ('fr', 'Français', 'French', 5), + ('it', 'Italiano', 'Italian', 6), + ('pt', 'Português', 'Portuguese', 7), + ('zh', '中文', 'Chinese (Simplified)', 8), + ('ja', '日本語', 'Japanese', 9), + ('ko', '한국어', 'Korean', 10), + ('ar', 'العربية', 'Arabic', 11), + ('hi', 'हिन्दी', 'Hindi', 12); + +-- --------------------------------------------------------------------------- +-- units + unit_translations +-- --------------------------------------------------------------------------- +INSERT INTO units (code, sort_order) VALUES + ('g', 1), + ('kg', 2), + ('ml', 3), + ('l', 4), + ('pcs', 5), + ('pack', 6); + +INSERT INTO unit_translations (unit_code, lang, name) VALUES + ('g', 'ru', 'г'), + ('kg', 'ru', 'кг'), + ('ml', 'ru', 'мл'), + ('l', 'ru', 'л'), + ('pcs', 'ru', 'шт'), + ('pack', 'ru', 'уп'); + +-- --------------------------------------------------------------------------- +-- ingredient_categories + ingredient_category_translations +-- --------------------------------------------------------------------------- +INSERT INTO ingredient_categories (slug, sort_order) VALUES + ('dairy', 1), + ('meat', 2), + ('produce', 3), + ('bakery', 4), + ('frozen', 5), + ('beverages', 6), + ('other', 7); + +INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES + ('dairy', 'ru', 'Молочные продукты'), + ('meat', 'ru', 'Мясо и птица'), + ('produce', 'ru', 'Овощи и фрукты'), + ('bakery', 'ru', 'Выпечка и хлеб'), + ('frozen', 'ru', 'Замороженные'), + ('beverages', 'ru', 'Напитки'), + ('other', 'ru', 'Прочее'); + +-- --------------------------------------------------------------------------- +-- cuisines + cuisine_translations +-- --------------------------------------------------------------------------- +INSERT INTO cuisines (slug, name, sort_order) VALUES + ('italian', 'Italian', 1), + ('french', 'French', 2), + ('russian', 'Russian', 3), + ('chinese', 'Chinese', 4), + ('japanese', 'Japanese', 5), + ('korean', 'Korean', 6), + ('mexican', 'Mexican', 7), + ('mediterranean', 'Mediterranean', 8), + ('indian', 'Indian', 9), + ('thai', 'Thai', 10), + ('american', 'American', 11), + ('georgian', 'Georgian', 12), + ('spanish', 'Spanish', 13), + ('german', 'German', 14), + ('middle_eastern', 'Middle Eastern', 15), + ('turkish', 'Turkish', 16), + ('greek', 'Greek', 17), + ('vietnamese', 'Vietnamese', 18), + ('other', 'Other', 19); + +INSERT INTO cuisine_translations (cuisine_slug, lang, name) VALUES + ('italian', 'ru', 'Итальянская'), + ('french', 'ru', 'Французская'), + ('russian', 'ru', 'Русская'), + ('chinese', 'ru', 'Китайская'), + ('japanese', 'ru', 'Японская'), + ('korean', 'ru', 'Корейская'), + ('mexican', 'ru', 'Мексиканская'), + ('mediterranean', 'ru', 'Средиземноморская'), + ('indian', 'ru', 'Индийская'), + ('thai', 'ru', 'Тайская'), + ('american', 'ru', 'Американская'), + ('georgian', 'ru', 'Грузинская'), + ('spanish', 'ru', 'Испанская'), + ('german', 'ru', 'Немецкая'), + ('middle_eastern', 'ru', 'Ближневосточная'), + ('turkish', 'ru', 'Турецкая'), + ('greek', 'ru', 'Греческая'), + ('vietnamese', 'ru', 'Вьетнамская'), + ('other', 'ru', 'Другая'); + +-- --------------------------------------------------------------------------- +-- tags + tag_translations +-- --------------------------------------------------------------------------- +INSERT INTO tags (slug, name, sort_order) VALUES + ('vegan', 'Vegan', 1), + ('vegetarian', 'Vegetarian', 2), + ('gluten_free', 'Gluten-Free', 3), + ('dairy_free', 'Dairy-Free', 4), + ('healthy', 'Healthy', 5), + ('quick', 'Quick', 6), + ('spicy', 'Spicy', 7), + ('sweet', 'Sweet', 8), + ('soup', 'Soup', 9), + ('salad', 'Salad', 10), + ('main_course', 'Main Course', 11), + ('appetizer', 'Appetizer', 12), + ('breakfast', 'Breakfast', 13), + ('dessert', 'Dessert', 14), + ('grilled', 'Grilled', 15), + ('baked', 'Baked', 16), + ('fried', 'Fried', 17), + ('raw', 'Raw', 18), + ('fermented', 'Fermented', 19); + +INSERT INTO tag_translations (tag_slug, lang, name) VALUES + ('vegan', 'ru', 'Веганское'), + ('vegetarian', 'ru', 'Вегетарианское'), + ('gluten_free', 'ru', 'Без глютена'), + ('dairy_free', 'ru', 'Без молока'), + ('healthy', 'ru', 'Здоровое'), + ('quick', 'ru', 'Быстрое'), + ('spicy', 'ru', 'Острое'), + ('sweet', 'ru', 'Сладкое'), + ('soup', 'ru', 'Суп'), + ('salad', 'ru', 'Салат'), + ('main_course', 'ru', 'Основное блюдо'), + ('appetizer', 'ru', 'Закуска'), + ('breakfast', 'ru', 'Завтрак'), + ('dessert', 'ru', 'Десерт'), + ('grilled', 'ru', 'Жареное на гриле'), + ('baked', 'ru', 'Запечённое'), + ('fried', 'ru', 'Жареное'), + ('raw', 'ru', 'Сырое'), + ('fermented', 'ru', 'Ферментированное'); + +-- --------------------------------------------------------------------------- +-- dish_categories + dish_category_translations +-- --------------------------------------------------------------------------- +INSERT INTO dish_categories (slug, name, sort_order) VALUES + ('soup', 'Soup', 1), + ('salad', 'Salad', 2), + ('main_course', 'Main Course', 3), + ('side_dish', 'Side Dish', 4), + ('appetizer', 'Appetizer', 5), + ('dessert', 'Dessert', 6), + ('breakfast', 'Breakfast', 7), + ('drink', 'Drink', 8), + ('bread', 'Bread', 9), + ('sauce', 'Sauce', 10), + ('snack', 'Snack', 11); + +INSERT INTO dish_category_translations (category_slug, lang, name) VALUES + ('soup', 'ru', 'Суп'), + ('salad', 'ru', 'Салат'), + ('main_course', 'ru', 'Основное блюдо'), + ('side_dish', 'ru', 'Гарнир'), + ('appetizer', 'ru', 'Закуска'), + ('dessert', 'ru', 'Десерт'), + ('breakfast', 'ru', 'Завтрак'), + ('drink', 'ru', 'Напиток'), + ('bread', 'ru', 'Выпечка'), + ('sauce', 'ru', 'Соус'), + ('snack', 'ru', 'Снэк'); + +-- +goose Down +DELETE FROM dish_category_translations; +DELETE FROM dish_categories; +DELETE FROM tag_translations; +DELETE FROM tags; +DELETE FROM cuisine_translations; +DELETE FROM cuisines; +DELETE FROM ingredient_category_translations; +DELETE FROM ingredient_categories; +DELETE FROM unit_translations; +DELETE FROM units; +DELETE FROM languages; diff --git a/backend/migrations/003_create_recipes.sql b/backend/migrations/003_create_recipes.sql deleted file mode 100644 index a5ef370..0000000 --- a/backend/migrations/003_create_recipes.sql +++ /dev/null @@ -1,58 +0,0 @@ --- +goose Up -CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user'); -CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard'); - -CREATE TABLE recipes ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - source recipe_source NOT NULL DEFAULT 'spoonacular', - spoonacular_id INTEGER UNIQUE, - - title VARCHAR(500) NOT NULL, - description TEXT, - title_ru VARCHAR(500), - description_ru TEXT, - - cuisine VARCHAR(100), - difficulty recipe_difficulty, - prep_time_min INTEGER, - cook_time_min INTEGER, - servings SMALLINT, - image_url TEXT, - - calories_per_serving DECIMAL(8, 2), - protein_per_serving DECIMAL(8, 2), - fat_per_serving DECIMAL(8, 2), - carbs_per_serving DECIMAL(8, 2), - fiber_per_serving DECIMAL(8, 2), - - ingredients JSONB NOT NULL DEFAULT '[]'::jsonb, - steps JSONB NOT NULL DEFAULT '[]'::jsonb, - tags JSONB NOT NULL DEFAULT '[]'::jsonb, - - avg_rating DECIMAL(3, 2) NOT NULL DEFAULT 0.0, - review_count INTEGER NOT NULL DEFAULT 0, - - created_by UUID REFERENCES users (id) ON DELETE SET NULL, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_recipes_title_fts ON recipes - USING GIN (to_tsvector('simple', - coalesce(title_ru, '') || ' ' || coalesce(title, ''))); - -CREATE INDEX idx_recipes_ingredients ON recipes USING GIN (ingredients); -CREATE INDEX idx_recipes_tags ON recipes USING GIN (tags); - -CREATE INDEX idx_recipes_cuisine ON recipes (cuisine); -CREATE INDEX idx_recipes_difficulty ON recipes (difficulty); -CREATE INDEX idx_recipes_prep_time ON recipes (prep_time_min); -CREATE INDEX idx_recipes_calories ON recipes (calories_per_serving); -CREATE INDEX idx_recipes_source ON recipes (source); -CREATE INDEX idx_recipes_rating ON recipes (avg_rating DESC); - --- +goose Down -DROP TABLE IF EXISTS recipes; -DROP TYPE IF EXISTS recipe_difficulty; -DROP TYPE IF EXISTS recipe_source; diff --git a/backend/migrations/004_create_saved_recipes.sql b/backend/migrations/004_create_saved_recipes.sql deleted file mode 100644 index c1f14f5..0000000 --- a/backend/migrations/004_create_saved_recipes.sql +++ /dev/null @@ -1,25 +0,0 @@ --- +goose Up -CREATE TABLE saved_recipes ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT, - cuisine TEXT, - difficulty TEXT, - prep_time_min INT, - cook_time_min INT, - servings INT, - image_url TEXT, - ingredients JSONB NOT NULL DEFAULT '[]', - steps JSONB NOT NULL DEFAULT '[]', - tags JSONB NOT NULL DEFAULT '[]', - nutrition JSONB, - source TEXT NOT NULL DEFAULT 'ai', - saved_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_saved_recipes_user_id ON saved_recipes(user_id); -CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes(user_id, saved_at DESC); - --- +goose Down -DROP TABLE saved_recipes; diff --git a/backend/migrations/005_add_ingredient_search_indexes.sql b/backend/migrations/005_add_ingredient_search_indexes.sql deleted file mode 100644 index 06838ab..0000000 --- a/backend/migrations/005_add_ingredient_search_indexes.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX idx_ingredient_mappings_canonical_ru_trgm - ON ingredient_mappings USING GIN (canonical_name_ru gin_trgm_ops); - -CREATE INDEX idx_ingredient_mappings_canonical_ru_fts - ON ingredient_mappings USING GIN (to_tsvector('russian', coalesce(canonical_name_ru, ''))); - --- +goose Down -DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_trgm; -DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_fts; diff --git a/backend/migrations/006_create_products.sql b/backend/migrations/006_create_products.sql deleted file mode 100644 index 2fb5015..0000000 --- a/backend/migrations/006_create_products.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up -CREATE TABLE products ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - mapping_id UUID REFERENCES ingredient_mappings(id), - name TEXT NOT NULL, - quantity DECIMAL(10, 2) NOT NULL DEFAULT 1, - unit TEXT NOT NULL DEFAULT 'pcs', - category TEXT, - storage_days INT NOT NULL DEFAULT 7, - added_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- expires_at is computed as (added_at + storage_days * INTERVAL '1 day') in queries. --- A stored generated column cannot be used because timestamptz + interval is STABLE, not IMMUTABLE. -CREATE INDEX idx_products_user_id ON products(user_id); - --- +goose Down -DROP TABLE products; diff --git a/backend/migrations/007_create_menu_plans.sql b/backend/migrations/007_create_menu_plans.sql deleted file mode 100644 index 5999c7b..0000000 --- a/backend/migrations/007_create_menu_plans.sql +++ /dev/null @@ -1,35 +0,0 @@ --- +goose Up - -CREATE TABLE menu_plans ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - week_start DATE NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(user_id, week_start) -); - -CREATE TABLE menu_items ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE, - day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), - meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')), - recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL, - recipe_data JSONB, - UNIQUE(menu_plan_id, day_of_week, meal_type) -); - --- Stores the generated shopping list for a menu plan. --- items is a JSONB array of {name, category, amount, unit, checked, in_stock}. -CREATE TABLE shopping_lists ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - menu_plan_id UUID REFERENCES menu_plans(id) ON DELETE CASCADE, - items JSONB NOT NULL DEFAULT '[]', - generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(user_id, menu_plan_id) -); - --- +goose Down -DROP TABLE shopping_lists; -DROP TABLE menu_items; -DROP TABLE menu_plans; diff --git a/backend/migrations/008_create_meal_diary.sql b/backend/migrations/008_create_meal_diary.sql deleted file mode 100644 index 3ac9258..0000000 --- a/backend/migrations/008_create_meal_diary.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up - -CREATE TABLE meal_diary ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - date DATE NOT NULL, - meal_type TEXT NOT NULL, - name TEXT NOT NULL, - portions DECIMAL(5,2) NOT NULL DEFAULT 1, - calories DECIMAL(8,2), - protein_g DECIMAL(8,2), - fat_g DECIMAL(8,2), - carbs_g DECIMAL(8,2), - source TEXT NOT NULL DEFAULT 'manual', - recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_meal_diary_user_date ON meal_diary(user_id, date); - --- +goose Down -DROP TABLE meal_diary; diff --git a/backend/migrations/009_create_translation_tables.sql b/backend/migrations/009_create_translation_tables.sql deleted file mode 100644 index e9a6893..0000000 --- a/backend/migrations/009_create_translation_tables.sql +++ /dev/null @@ -1,118 +0,0 @@ --- +goose Up - --- --------------------------------------------------------------------------- --- recipe_translations --- Stores per-language overrides for the catalog recipe fields that contain --- human-readable text (title, description, ingredients list, step descriptions). --- The base `recipes` row always holds the English (canonical) content. --- --------------------------------------------------------------------------- -CREATE TABLE recipe_translations ( - recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - title VARCHAR(500), - description TEXT, - ingredients JSONB, - steps JSONB, - PRIMARY KEY (recipe_id, lang) -); - -CREATE INDEX idx_recipe_translations_recipe_id ON recipe_translations (recipe_id); - --- --------------------------------------------------------------------------- --- saved_recipe_translations --- Stores per-language translations for user-saved (AI-generated) recipes. --- The base `saved_recipes` row always holds the English canonical content. --- Translations are generated on demand by the AI layer and recorded here. --- --------------------------------------------------------------------------- -CREATE TABLE saved_recipe_translations ( - saved_recipe_id UUID NOT NULL REFERENCES saved_recipes(id) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - title VARCHAR(500), - description TEXT, - ingredients JSONB, - steps JSONB, - generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (saved_recipe_id, lang) -); - -CREATE INDEX idx_saved_recipe_translations_recipe_id ON saved_recipe_translations (saved_recipe_id); - --- --------------------------------------------------------------------------- --- ingredient_translations --- Stores per-language names (and optional aliases) for ingredient mappings. --- The base `ingredient_mappings` row holds the English canonical name. --- --------------------------------------------------------------------------- -CREATE TABLE ingredient_translations ( - ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - name VARCHAR(255) NOT NULL, - aliases JSONB NOT NULL DEFAULT '[]'::jsonb, - PRIMARY KEY (ingredient_id, lang) -); - -CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id); - --- --------------------------------------------------------------------------- --- Migrate existing Russian data from _ru columns into the translation tables. --- --------------------------------------------------------------------------- - --- Recipe translations: title_ru / description_ru at the row level, plus the --- embedded name_ru / unit_ru fields inside the ingredients JSONB array, and --- description_ru inside the steps JSONB array. -INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps) -SELECT - id, - 'ru', - title_ru, - description_ru, - -- Rebuild ingredients array with Russian name/unit substituted in. - CASE - WHEN jsonb_array_length(ingredients) > 0 THEN ( - SELECT COALESCE( - jsonb_agg( - jsonb_build_object( - 'spoonacular_id', elem->>'spoonacular_id', - 'mapping_id', elem->>'mapping_id', - 'name', COALESCE(NULLIF(elem->>'name_ru', ''), elem->>'name'), - 'amount', (elem->>'amount')::numeric, - 'unit', COALESCE(NULLIF(elem->>'unit_ru', ''), elem->>'unit'), - 'optional', (elem->>'optional')::boolean - ) - ), - '[]'::jsonb - ) - FROM jsonb_array_elements(ingredients) AS elem - ) - ELSE NULL - END, - -- Rebuild steps array with Russian description substituted in. - CASE - WHEN jsonb_array_length(steps) > 0 THEN ( - SELECT COALESCE( - jsonb_agg( - jsonb_build_object( - 'number', (elem->>'number')::int, - 'description', COALESCE(NULLIF(elem->>'description_ru', ''), elem->>'description'), - 'timer_seconds', elem->'timer_seconds', - 'image_url', elem->>'image_url' - ) - ), - '[]'::jsonb - ) - FROM jsonb_array_elements(steps) AS elem - ) - ELSE NULL - END -FROM recipes -WHERE title_ru IS NOT NULL; - --- Ingredient translations: canonical_name_ru. -INSERT INTO ingredient_translations (ingredient_id, lang, name) -SELECT id, 'ru', canonical_name_ru -FROM ingredient_mappings -WHERE canonical_name_ru IS NOT NULL; - --- +goose Down -DROP TABLE IF EXISTS ingredient_translations; -DROP TABLE IF EXISTS saved_recipe_translations; -DROP TABLE IF EXISTS recipe_translations; diff --git a/backend/migrations/010_drop_ru_columns.sql b/backend/migrations/010_drop_ru_columns.sql deleted file mode 100644 index 057f1ff..0000000 --- a/backend/migrations/010_drop_ru_columns.sql +++ /dev/null @@ -1,36 +0,0 @@ --- +goose Up - --- Drop the full-text search index that references the soon-to-be-removed --- title_ru column. -DROP INDEX IF EXISTS idx_recipes_title_fts; - --- Remove legacy _ru columns from recipes now that the data lives in --- recipe_translations (migration 009). -ALTER TABLE recipes - DROP COLUMN IF EXISTS title_ru, - DROP COLUMN IF EXISTS description_ru; - --- Remove the legacy Russian name column from ingredient_mappings. -ALTER TABLE ingredient_mappings - DROP COLUMN IF EXISTS canonical_name_ru; - --- Recreate the FTS index on the English title only. --- Cross-language search is now handled at the application level by querying --- the appropriate translation row. -CREATE INDEX idx_recipes_title_fts ON recipes - USING GIN (to_tsvector('simple', coalesce(title, ''))); - --- +goose Down -DROP INDEX IF EXISTS idx_recipes_title_fts; - -ALTER TABLE recipes - ADD COLUMN title_ru VARCHAR(500), - ADD COLUMN description_ru TEXT; - -ALTER TABLE ingredient_mappings - ADD COLUMN canonical_name_ru VARCHAR(255); - --- Restore the bilingual FTS index. -CREATE INDEX idx_recipes_title_fts ON recipes - USING GIN (to_tsvector('simple', - coalesce(title_ru, '') || ' ' || coalesce(title, ''))); diff --git a/backend/migrations/011_replace_age_with_date_of_birth.sql b/backend/migrations/011_replace_age_with_date_of_birth.sql deleted file mode 100644 index 58c80e5..0000000 --- a/backend/migrations/011_replace_age_with_date_of_birth.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up -ALTER TABLE users ADD COLUMN date_of_birth DATE; -UPDATE users - SET date_of_birth = (CURRENT_DATE - (age * INTERVAL '1 year'))::DATE - WHERE age IS NOT NULL; -ALTER TABLE users DROP COLUMN age; - --- +goose Down -ALTER TABLE users ADD COLUMN age SMALLINT; -UPDATE users - SET age = EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth))::SMALLINT - WHERE date_of_birth IS NOT NULL; -ALTER TABLE users DROP COLUMN date_of_birth; diff --git a/backend/migrations/012_refactor_ingredients.sql b/backend/migrations/012_refactor_ingredients.sql deleted file mode 100644 index 9f27189..0000000 --- a/backend/migrations/012_refactor_ingredients.sql +++ /dev/null @@ -1,89 +0,0 @@ --- +goose Up - --- 1. ingredient_categories: slug-keyed table (the 7 known slugs) -CREATE TABLE ingredient_categories ( - slug VARCHAR(50) PRIMARY KEY, - sort_order SMALLINT NOT NULL DEFAULT 0 -); -INSERT INTO ingredient_categories (slug, sort_order) VALUES - ('dairy', 1), ('meat', 2), ('produce', 3), - ('bakery', 4), ('frozen', 5), ('beverages', 6), ('other', 7); - --- 2. ingredient_category_translations -CREATE TABLE ingredient_category_translations ( - category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (category_slug, lang) -); -INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES - ('dairy', 'ru', 'Молочные продукты'), - ('meat', 'ru', 'Мясо и птица'), - ('produce', 'ru', 'Овощи и фрукты'), - ('bakery', 'ru', 'Выпечка и хлеб'), - ('frozen', 'ru', 'Замороженные'), - ('beverages', 'ru', 'Напитки'), - ('other', 'ru', 'Прочее'); - --- 3. Nullify any unknown category values before adding FK -UPDATE ingredient_mappings - SET category = NULL - WHERE category IS NOT NULL - AND category NOT IN (SELECT slug FROM ingredient_categories); - -ALTER TABLE ingredient_mappings - ADD CONSTRAINT fk_ingredient_category - FOREIGN KEY (category) REFERENCES ingredient_categories(slug); - --- 4. ingredient_aliases table -CREATE TABLE ingredient_aliases ( - ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - alias TEXT NOT NULL, - PRIMARY KEY (ingredient_id, lang, alias) -); -CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang); -CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops); - --- 5. Migrate English aliases from ingredient_mappings.aliases -INSERT INTO ingredient_aliases (ingredient_id, lang, alias) -SELECT im.id, 'en', a.val -FROM ingredient_mappings im, - jsonb_array_elements_text(im.aliases) a(val) -ON CONFLICT DO NOTHING; - --- 6. Migrate per-language aliases from ingredient_translations.aliases -INSERT INTO ingredient_aliases (ingredient_id, lang, alias) -SELECT it.ingredient_id, it.lang, a.val -FROM ingredient_translations it, - jsonb_array_elements_text(it.aliases) a(val) -ON CONFLICT DO NOTHING; - --- 7. Drop aliases JSONB columns -DROP INDEX IF EXISTS idx_ingredient_mappings_aliases; -ALTER TABLE ingredient_mappings DROP COLUMN aliases; -ALTER TABLE ingredient_translations DROP COLUMN aliases; - --- 8. Drop spoonacular_id -ALTER TABLE ingredient_mappings DROP COLUMN spoonacular_id; - --- 9. Unique constraint on canonical_name (replaces spoonacular_id as conflict key) -ALTER TABLE ingredient_mappings - ADD CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name); - --- +goose Down -ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS uq_ingredient_canonical_name; -ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_ingredient_category; -ALTER TABLE ingredient_mappings ADD COLUMN spoonacular_id INTEGER; -ALTER TABLE ingredient_translations ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb; -ALTER TABLE ingredient_mappings ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb; --- Restore aliases JSONB from ingredient_aliases (best-effort) -UPDATE ingredient_mappings im - SET aliases = COALESCE( - (SELECT json_agg(ia.alias) FROM ingredient_aliases ia - WHERE ia.ingredient_id = im.id AND ia.lang = 'en'), - '[]'::json); -DROP TABLE IF EXISTS ingredient_aliases; -DROP TABLE IF EXISTS ingredient_category_translations; -DROP TABLE IF EXISTS ingredient_categories; -CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN (aliases); diff --git a/backend/migrations/013_create_languages.sql b/backend/migrations/013_create_languages.sql deleted file mode 100644 index 82e2350..0000000 --- a/backend/migrations/013_create_languages.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up -CREATE TABLE languages ( - code VARCHAR(10) PRIMARY KEY, - native_name TEXT NOT NULL, - english_name TEXT NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT true, - sort_order SMALLINT NOT NULL DEFAULT 0 -); -INSERT INTO languages (code, native_name, english_name, sort_order) VALUES - ('en', 'English', 'English', 1), - ('ru', 'Русский', 'Russian', 2), - ('es', 'Español', 'Spanish', 3), - ('de', 'Deutsch', 'German', 4), - ('fr', 'Français', 'French', 5), - ('it', 'Italiano', 'Italian', 6), - ('pt', 'Português', 'Portuguese', 7), - ('zh', '中文', 'Chinese (Simplified)', 8), - ('ja', '日本語', 'Japanese', 9), - ('ko', '한국어', 'Korean', 10), - ('ar', 'العربية', 'Arabic', 11), - ('hi', 'हिन्दी', 'Hindi', 12); - --- +goose Down -DROP TABLE IF EXISTS languages; diff --git a/backend/migrations/014_create_units.sql b/backend/migrations/014_create_units.sql deleted file mode 100644 index 54f8446..0000000 --- a/backend/migrations/014_create_units.sql +++ /dev/null @@ -1,58 +0,0 @@ --- +goose Up - -CREATE TABLE units ( - code VARCHAR(20) PRIMARY KEY, - sort_order SMALLINT NOT NULL DEFAULT 0 -); -INSERT INTO units (code, sort_order) VALUES - ('g', 1), - ('kg', 2), - ('ml', 3), - ('l', 4), - ('pcs', 5), - ('pack', 6); - -CREATE TABLE unit_translations ( - unit_code VARCHAR(20) NOT NULL REFERENCES units(code) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (unit_code, lang) -); -INSERT INTO unit_translations (unit_code, lang, name) VALUES - ('g', 'ru', 'г'), - ('kg', 'ru', 'кг'), - ('ml', 'ru', 'мл'), - ('l', 'ru', 'л'), - ('pcs', 'ru', 'шт'), - ('pack', 'ru', 'уп'); - --- Normalize products.unit from Russian display strings to English codes -UPDATE products SET unit = 'g' WHERE unit = 'г'; -UPDATE products SET unit = 'kg' WHERE unit = 'кг'; -UPDATE products SET unit = 'ml' WHERE unit = 'мл'; -UPDATE products SET unit = 'l' WHERE unit = 'л'; -UPDATE products SET unit = 'pcs' WHERE unit = 'шт'; -UPDATE products SET unit = 'pack' WHERE unit = 'уп'; --- Normalize any remaining unknown values -UPDATE products SET unit = 'pcs' WHERE unit NOT IN (SELECT code FROM units); - --- Nullify unknown default_unit values in ingredient_mappings before adding FK -UPDATE ingredient_mappings - SET default_unit = NULL - WHERE default_unit IS NOT NULL - AND default_unit NOT IN (SELECT code FROM units); - --- Foreign key constraints -ALTER TABLE products - ADD CONSTRAINT fk_product_unit - FOREIGN KEY (unit) REFERENCES units(code); - -ALTER TABLE ingredient_mappings - ADD CONSTRAINT fk_default_unit - FOREIGN KEY (default_unit) REFERENCES units(code); - --- +goose Down -ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_default_unit; -ALTER TABLE products DROP CONSTRAINT IF EXISTS fk_product_unit; -DROP TABLE IF EXISTS unit_translations; -DROP TABLE IF EXISTS units; diff --git a/client/lib/core/api/cuisine_repository.dart b/client/lib/core/api/cuisine_repository.dart new file mode 100644 index 0000000..e19d77c --- /dev/null +++ b/client/lib/core/api/cuisine_repository.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import 'api_client.dart'; +import '../../shared/models/cuisine.dart'; + +class CuisineRepository { + final ApiClient _api; + CuisineRepository(this._api); + + Future> fetchCuisines() async { + final data = await _api.get('/cuisines'); + final List items = data['cuisines'] as List; + return items + .map((e) => Cuisine.fromJson(e as Map)) + .toList(); + } +} + +final cuisineRepositoryProvider = Provider( + (ref) => CuisineRepository(ref.watch(apiClientProvider)), +); diff --git a/client/lib/core/api/tag_repository.dart b/client/lib/core/api/tag_repository.dart new file mode 100644 index 0000000..8ca2716 --- /dev/null +++ b/client/lib/core/api/tag_repository.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import 'api_client.dart'; +import '../../shared/models/tag.dart'; + +class TagRepository { + final ApiClient _api; + TagRepository(this._api); + + Future> fetchTags() async { + final data = await _api.get('/tags'); + final List items = data['tags'] as List; + return items + .map((e) => Tag.fromJson(e as Map)) + .toList(); + } +} + +final tagRepositoryProvider = Provider( + (ref) => TagRepository(ref.watch(apiClientProvider)), +); diff --git a/client/lib/core/locale/cuisine_provider.dart b/client/lib/core/locale/cuisine_provider.dart new file mode 100644 index 0000000..cc8be82 --- /dev/null +++ b/client/lib/core/locale/cuisine_provider.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/cuisine_repository.dart'; +import '../../shared/models/cuisine.dart'; +import 'language_provider.dart'; + +/// Fetches and caches cuisines with localized names. +/// Returns list of [Cuisine] objects. +/// Re-fetches automatically when languageProvider changes. +final cuisinesProvider = FutureProvider>((ref) { + ref.watch(languageProvider); // invalidate when language changes + return ref.read(cuisineRepositoryProvider).fetchCuisines(); +}); + +/// Convenience provider that returns a slug → localized name map. +final cuisineNamesProvider = FutureProvider>((ref) async { + final cuisines = await ref.watch(cuisinesProvider.future); + return {for (final c in cuisines) c.slug: c.name}; +}); diff --git a/client/lib/core/locale/tag_provider.dart b/client/lib/core/locale/tag_provider.dart new file mode 100644 index 0000000..689c45f --- /dev/null +++ b/client/lib/core/locale/tag_provider.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/tag_repository.dart'; +import '../../shared/models/tag.dart'; +import 'language_provider.dart'; + +/// Fetches and caches tags with localized names. +/// Returns list of [Tag] objects. +/// Re-fetches automatically when languageProvider changes. +final tagsProvider = FutureProvider>((ref) { + ref.watch(languageProvider); // invalidate when language changes + return ref.read(tagRepositoryProvider).fetchTags(); +}); + +/// Convenience provider that returns a slug → localized name map. +final tagNamesProvider = FutureProvider>((ref) async { + final tags = await ref.watch(tagsProvider.future); + return {for (final t in tags) t.slug: t.name}; +}); diff --git a/client/lib/features/recipes/recipe_detail_screen.dart b/client/lib/features/recipes/recipe_detail_screen.dart index 20431d7..a3a344b 100644 --- a/client/lib/features/recipes/recipe_detail_screen.dart +++ b/client/lib/features/recipes/recipe_detail_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/locale/cuisine_provider.dart'; +import '../../core/locale/tag_provider.dart'; import '../../core/locale/unit_provider.dart'; import '../../core/theme/app_colors.dart'; import '../../shared/models/recipe.dart'; @@ -200,7 +202,7 @@ class _PlaceholderImage extends StatelessWidget { ); } -class _MetaChips extends StatelessWidget { +class _MetaChips extends ConsumerWidget { final int? prepTimeMin; final int? cookTimeMin; final String? difficulty; @@ -216,8 +218,9 @@ class _MetaChips extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0); + final cuisineNames = ref.watch(cuisineNamesProvider).valueOrNull ?? {}; return Wrap( spacing: 8, runSpacing: 4, @@ -227,7 +230,9 @@ class _MetaChips extends StatelessWidget { if (difficulty != null) _Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)), if (cuisine != null) - _Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)), + _Chip( + icon: Icons.public, + label: cuisineNames[cuisine!] ?? cuisine!), if (servings != null) _Chip(icon: Icons.people, label: '$servings порц.'), ], @@ -240,15 +245,6 @@ class _MetaChips extends StatelessWidget { 'hard' => 'Сложно', _ => d, }; - - String _cuisineLabel(String c) => switch (c) { - 'russian' => 'Русская', - 'asian' => 'Азиатская', - 'european' => 'Европейская', - 'mediterranean' => 'Средиземноморская', - 'american' => 'Американская', - _ => 'Другая', - }; } class _Chip extends StatelessWidget { @@ -347,20 +343,24 @@ class _NutCell extends StatelessWidget { ); } -class _TagsRow extends StatelessWidget { +class _TagsRow extends ConsumerWidget { final List tags; const _TagsRow({required this.tags}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final tagNames = ref.watch(tagNamesProvider).valueOrNull ?? {}; return Wrap( spacing: 6, runSpacing: 4, children: tags .map( (t) => Chip( - label: Text(t, style: const TextStyle(fontSize: 11)), + label: Text( + tagNames[t] ?? t, + style: const TextStyle(fontSize: 11), + ), backgroundColor: AppColors.primary.withValues(alpha: 0.15), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, @@ -402,7 +402,7 @@ class _IngredientsSection extends ConsumerWidget { const SizedBox(width: 10), Expanded(child: Text(ing.name)), Text( - '${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.unit] ?? ing.unit}', + '${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.effectiveUnit] ?? ing.effectiveUnit}', style: const TextStyle( color: AppColors.textSecondary, fontSize: 13), ), diff --git a/client/lib/shared/models/cuisine.dart b/client/lib/shared/models/cuisine.dart new file mode 100644 index 0000000..8d52764 --- /dev/null +++ b/client/lib/shared/models/cuisine.dart @@ -0,0 +1,13 @@ +class Cuisine { + final String slug; + final String name; + + const Cuisine({required this.slug, required this.name}); + + factory Cuisine.fromJson(Map json) { + return Cuisine( + slug: json['slug'] as String? ?? '', + name: json['name'] as String? ?? '', + ); + } +} diff --git a/client/lib/shared/models/diary_entry.dart b/client/lib/shared/models/diary_entry.dart index 588a59a..d1c3cd3 100644 --- a/client/lib/shared/models/diary_entry.dart +++ b/client/lib/shared/models/diary_entry.dart @@ -9,7 +9,9 @@ class DiaryEntry { final double? fatG; final double? carbsG; final String source; + final String? dishId; final String? recipeId; + final double? portionG; const DiaryEntry({ required this.id, @@ -22,7 +24,9 @@ class DiaryEntry { this.fatG, this.carbsG, required this.source, + this.dishId, this.recipeId, + this.portionG, }); factory DiaryEntry.fromJson(Map json) { @@ -37,7 +41,9 @@ class DiaryEntry { fatG: (json['fat_g'] as num?)?.toDouble(), carbsG: (json['carbs_g'] as num?)?.toDouble(), source: json['source'] as String? ?? 'manual', + dishId: json['dish_id'] as String?, recipeId: json['recipe_id'] as String?, + portionG: (json['portion_g'] as num?)?.toDouble(), ); } diff --git a/client/lib/shared/models/recipe.dart b/client/lib/shared/models/recipe.dart index 67759d0..07844aa 100644 --- a/client/lib/shared/models/recipe.dart +++ b/client/lib/shared/models/recipe.dart @@ -59,14 +59,25 @@ class Recipe { class RecipeIngredient { final String name; final double amount; + + /// Unit from Gemini recommendations (free-form string). + @JsonKey(defaultValue: '') final String unit; + /// Unit code from the DB-backed saved recipes endpoint. + @JsonKey(name: 'unit_code') + final String? unitCode; + const RecipeIngredient({ required this.name, required this.amount, - required this.unit, + this.unit = '', + this.unitCode, }); + /// Returns the best available unit identifier for display / lookup. + String get effectiveUnit => unitCode ?? unit; + factory RecipeIngredient.fromJson(Map json) => _$RecipeIngredientFromJson(json); Map toJson() => _$RecipeIngredientToJson(this); diff --git a/client/lib/shared/models/recipe.g.dart b/client/lib/shared/models/recipe.g.dart index a94ec8d..c1b0725 100644 --- a/client/lib/shared/models/recipe.g.dart +++ b/client/lib/shared/models/recipe.g.dart @@ -55,7 +55,8 @@ RecipeIngredient _$RecipeIngredientFromJson(Map json) => RecipeIngredient( name: json['name'] as String, amount: (json['amount'] as num).toDouble(), - unit: json['unit'] as String, + unit: json['unit'] as String? ?? '', + unitCode: json['unit_code'] as String?, ); Map _$RecipeIngredientToJson(RecipeIngredient instance) => @@ -63,6 +64,7 @@ Map _$RecipeIngredientToJson(RecipeIngredient instance) => 'name': instance.name, 'amount': instance.amount, 'unit': instance.unit, + 'unit_code': instance.unitCode, }; RecipeStep _$RecipeStepFromJson(Map json) => RecipeStep( diff --git a/client/lib/shared/models/saved_recipe.dart b/client/lib/shared/models/saved_recipe.dart index 40d8edc..e894d81 100644 --- a/client/lib/shared/models/saved_recipe.dart +++ b/client/lib/shared/models/saved_recipe.dart @@ -1,47 +1,36 @@ -import 'package:json_annotation/json_annotation.dart'; - import 'recipe.dart'; -part 'saved_recipe.g.dart'; - -@JsonSerializable(explicitToJson: true) +/// A user's bookmarked recipe. Display fields are joined from dishes + recipes. class SavedRecipe { final String id; + + final String recipeId; + final String title; final String? description; + + /// Mapped from cuisine_slug in the API response. final String? cuisine; + final String? difficulty; - - @JsonKey(name: 'prep_time_min') final int? prepTimeMin; - - @JsonKey(name: 'cook_time_min') final int? cookTimeMin; - final int? servings; - - @JsonKey(name: 'image_url') final String? imageUrl; - - @JsonKey(defaultValue: []) final List ingredients; - - @JsonKey(defaultValue: []) final List steps; - - @JsonKey(defaultValue: []) final List tags; - - @JsonKey(name: 'nutrition_per_serving') - final NutritionInfo? nutrition; - - final String source; - - @JsonKey(name: 'saved_at') final DateTime savedAt; + // Individual nutrition columns (nullable). + final double? caloriesPerServing; + final double? proteinPerServing; + final double? fatPerServing; + final double? carbsPerServing; + const SavedRecipe({ required this.id, + required this.recipeId, required this.title, this.description, this.cuisine, @@ -53,12 +42,81 @@ class SavedRecipe { this.ingredients = const [], this.steps = const [], this.tags = const [], - this.nutrition, - required this.source, required this.savedAt, + this.caloriesPerServing, + this.proteinPerServing, + this.fatPerServing, + this.carbsPerServing, }); - factory SavedRecipe.fromJson(Map json) => - _$SavedRecipeFromJson(json); - Map toJson() => _$SavedRecipeToJson(this); + /// Builds a [NutritionInfo] from the individual per-serving columns, + /// or null when no calorie data is available. + NutritionInfo? get nutrition { + if (caloriesPerServing == null) return null; + return NutritionInfo( + calories: caloriesPerServing!, + proteinG: proteinPerServing ?? 0, + fatG: fatPerServing ?? 0, + carbsG: carbsPerServing ?? 0, + approximate: false, + ); + } + + factory SavedRecipe.fromJson(Map json) { + return SavedRecipe( + id: json['id'] as String? ?? '', + recipeId: json['recipe_id'] as String? ?? '', + title: json['title'] as String? ?? '', + description: json['description'] as String?, + cuisine: json['cuisine_slug'] as String?, + difficulty: json['difficulty'] as String?, + prepTimeMin: (json['prep_time_min'] as num?)?.toInt(), + cookTimeMin: (json['cook_time_min'] as num?)?.toInt(), + servings: (json['servings'] as num?)?.toInt(), + imageUrl: json['image_url'] as String?, + ingredients: (json['ingredients'] as List?) + ?.map((e) => + RecipeIngredient.fromJson(e as Map)) + .toList() ?? + [], + steps: (json['steps'] as List?) + ?.map( + (e) => RecipeStep.fromJson(e as Map)) + .toList() ?? + [], + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + savedAt: DateTime.tryParse(json['saved_at'] as String? ?? '') ?? + DateTime.now(), + caloriesPerServing: + (json['calories_per_serving'] as num?)?.toDouble(), + proteinPerServing: + (json['protein_per_serving'] as num?)?.toDouble(), + fatPerServing: (json['fat_per_serving'] as num?)?.toDouble(), + carbsPerServing: (json['carbs_per_serving'] as num?)?.toDouble(), + ); + } + + Map toJson() => { + 'id': id, + 'recipe_id': recipeId, + 'title': title, + 'description': description, + 'cuisine_slug': cuisine, + 'difficulty': difficulty, + 'prep_time_min': prepTimeMin, + 'cook_time_min': cookTimeMin, + 'servings': servings, + 'image_url': imageUrl, + 'ingredients': ingredients.map((e) => e.toJson()).toList(), + 'steps': steps.map((e) => e.toJson()).toList(), + 'tags': tags, + 'saved_at': savedAt.toIso8601String(), + 'calories_per_serving': caloriesPerServing, + 'protein_per_serving': proteinPerServing, + 'fat_per_serving': fatPerServing, + 'carbs_per_serving': carbsPerServing, + }; } diff --git a/client/lib/shared/models/saved_recipe.g.dart b/client/lib/shared/models/saved_recipe.g.dart index a041e83..b6a0b6c 100644 --- a/client/lib/shared/models/saved_recipe.g.dart +++ b/client/lib/shared/models/saved_recipe.g.dart @@ -1,57 +1,3 @@ // GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'saved_recipe.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SavedRecipe _$SavedRecipeFromJson(Map json) => SavedRecipe( - id: json['id'] as String, - title: json['title'] as String, - description: json['description'] as String?, - cuisine: json['cuisine'] as String?, - difficulty: json['difficulty'] as String?, - prepTimeMin: (json['prep_time_min'] as num?)?.toInt(), - cookTimeMin: (json['cook_time_min'] as num?)?.toInt(), - servings: (json['servings'] as num?)?.toInt(), - imageUrl: json['image_url'] as String?, - ingredients: - (json['ingredients'] as List?) - ?.map((e) => RecipeIngredient.fromJson(e as Map)) - .toList() ?? - [], - steps: - (json['steps'] as List?) - ?.map((e) => RecipeStep.fromJson(e as Map)) - .toList() ?? - [], - tags: - (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], - nutrition: json['nutrition_per_serving'] == null - ? null - : NutritionInfo.fromJson( - json['nutrition_per_serving'] as Map, - ), - source: json['source'] as String, - savedAt: DateTime.parse(json['saved_at'] as String), -); - -Map _$SavedRecipeToJson(SavedRecipe instance) => - { - 'id': instance.id, - 'title': instance.title, - 'description': instance.description, - 'cuisine': instance.cuisine, - 'difficulty': instance.difficulty, - 'prep_time_min': instance.prepTimeMin, - 'cook_time_min': instance.cookTimeMin, - 'servings': instance.servings, - 'image_url': instance.imageUrl, - 'ingredients': instance.ingredients.map((e) => e.toJson()).toList(), - 'steps': instance.steps.map((e) => e.toJson()).toList(), - 'tags': instance.tags, - 'nutrition_per_serving': instance.nutrition?.toJson(), - 'source': instance.source, - 'saved_at': instance.savedAt.toIso8601String(), - }; +// This file is intentionally empty. +// saved_recipe.dart now uses a manually written fromJson/toJson. diff --git a/client/lib/shared/models/tag.dart b/client/lib/shared/models/tag.dart new file mode 100644 index 0000000..fba31db --- /dev/null +++ b/client/lib/shared/models/tag.dart @@ -0,0 +1,13 @@ +class Tag { + final String slug; + final String name; + + const Tag({required this.slug, required this.name}); + + factory Tag.fromJson(Map json) { + return Tag( + slug: json['slug'] as String? ?? '', + name: json['name'] as String? ?? '', + ); + } +}