feat: core schema redesign — dishes, structured recipes, cuisines, tags (iteration 7)

Replaces the flat JSONB-based recipe schema with a normalized relational model:

Schema (migrations consolidated to 001_initial_schema + 002_seed_data):
- New: dishes, dish_translations, dish_tags — canonical dish catalog
- New: cuisines, tags, dish_categories with _translations tables + full seed data
- New: recipe_ingredients, recipe_steps with _translations (replaces JSONB blobs)
- New: user_saved_recipes thin bookmark (drops saved_recipes + saved_recipe_translations)
- New: product_ingredients M2M table
- recipes: now a cooking variant of a dish (dish_id FK, no title/JSONB columns)
- recipe_translations: repurposed to per-language notes only
- products: mapping_id → primary_ingredient_id
- menu_items: recipe_id FK → recipes; adds dish_id
- meal_diary: adds dish_id, recipe_id → recipes, portion_g

Backend (Go):
- New packages: internal/cuisine, internal/tag, internal/dish (registry + handler + repo)
- New GET /cuisines, GET /tags (public), GET /dishes, GET /dishes/{id}, GET /recipes/{id}
- recipe, savedrecipe, menu, diary, product, ingredient packages updated for new schema

Flutter:
- New models: Cuisine, Tag; new providers: cuisineNamesProvider, tagNamesProvider
- recipe.dart: RecipeIngredient gains unit_code + effectiveUnit getter
- saved_recipe.dart: thin model, manual fromJson, computed nutrition getter
- diary_entry.dart: adds dishId, recipeId, portionG
- recipe_detail_screen.dart: localized cuisine/tag names via providers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 18:01:24 +02:00
parent 55d01400b0
commit 61feb91bba
52 changed files with 2479 additions and 1492 deletions

View File

@@ -4,3 +4,49 @@
- All code comments must be written in **English**. - All code comments must be written in **English**.
- All git commit messages 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.

View File

@@ -12,8 +12,10 @@ import (
"github.com/food-ai/backend/internal/auth" "github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config" "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/database"
"github.com/food-ai/backend/internal/diary" "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/gemini"
"github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient" "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/units"
"github.com/food-ai/backend/internal/pexels" "github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/product" "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/recognition"
"github.com/food-ai/backend/internal/recommendation" "github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/server" "github.com/food-ai/backend/internal/server"
"github.com/food-ai/backend/internal/tag"
"github.com/food-ai/backend/internal/user" "github.com/food-ai/backend/internal/user"
) )
@@ -84,6 +88,16 @@ func run() error {
} }
slog.Info("units loaded", "count", len(units.Records)) 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 // Firebase auth
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile) firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
if err != nil { if err != nil {
@@ -123,13 +137,21 @@ func run() error {
// Recommendation domain // Recommendation domain
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo) 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 // Saved recipes domain
savedRecipeRepo := savedrecipe.NewRepository(pool) savedRecipeRepo := savedrecipe.NewRepository(pool, dishRepo)
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo) savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
// Menu domain // Menu domain
menuRepo := menu.NewRepository(pool) menuRepo := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, savedRecipeRepo) menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, dishRepo)
// Diary domain // Diary domain
diaryRepo := diary.NewRepository(pool) diaryRepo := diary.NewRepository(pool)
@@ -151,6 +173,8 @@ func run() error {
menuHandler, menuHandler,
diaryHandler, diaryHandler,
homeHandler, homeHandler,
dishHandler,
recipeHandler,
authMW, authMW,
cfg.AllowedOrigins, cfg.AllowedOrigins,
) )

View File

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

View File

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

View File

@@ -14,7 +14,9 @@ type Entry struct {
FatG *float64 `json:"fat_g,omitempty"` FatG *float64 `json:"fat_g,omitempty"`
CarbsG *float64 `json:"carbs_g,omitempty"` CarbsG *float64 `json:"carbs_g,omitempty"`
Source string `json:"source"` Source string `json:"source"`
DishID *string `json:"dish_id,omitempty"`
RecipeID *string `json:"recipe_id,omitempty"` RecipeID *string `json:"recipe_id,omitempty"`
PortionG *float64 `json:"portion_g,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -29,5 +31,7 @@ type CreateRequest struct {
FatG *float64 `json:"fat_g"` FatG *float64 `json:"fat_g"`
CarbsG *float64 `json:"carbs_g"` CarbsG *float64 `json:"carbs_g"`
Source string `json:"source"` Source string `json:"source"`
DishID *string `json:"dish_id"`
RecipeID *string `json:"recipe_id"` RecipeID *string `json:"recipe_id"`
PortionG *float64 `json:"portion_g"`
} }

View File

@@ -27,7 +27,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, date::text, meal_type, name, portions, SELECT id, date::text, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g, calories, protein_g, fat_g, carbs_g,
source, recipe_id, created_at source, dish_id, recipe_id, portion_g, created_at
FROM meal_diary FROM meal_diary
WHERE user_id = $1 AND date = $2::date WHERE user_id = $1 AND date = $2::date
ORDER BY created_at ASC`, userID, 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, ` row := r.pool.QueryRow(ctx, `
INSERT INTO meal_diary (user_id, date, meal_type, name, portions, INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g, source, recipe_id) 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) VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, date::text, meal_type, name, portions, 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, userID, req.Date, req.MealType, req.Name, portions,
req.Calories, req.ProteinG, req.FatG, req.CarbsG, req.Calories, req.ProteinG, req.FatG, req.CarbsG,
source, req.RecipeID, source, req.DishID, req.RecipeID, req.PortionG,
) )
return scanEntry(row) return scanEntry(row)
} }
@@ -95,7 +95,7 @@ func scanEntry(s scannable) (*Entry, error) {
err := s.Scan( err := s.Scan(
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions, &e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG, &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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "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 { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -21,11 +21,11 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool} 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. // Conflict is resolved on canonical_name.
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) { func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
query := ` query := `
INSERT INTO ingredient_mappings ( INSERT INTO ingredients (
canonical_name, canonical_name,
category, default_unit, category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, 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) 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. // CanonicalName and aliases are resolved for the language stored in ctx.
// Returns nil, nil if not found. // Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) { 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.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, im.storage_days, im.created_at, im.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases COALESCE(al.aliases, '[]'::json) AS aliases
FROM ingredient_mappings im FROM ingredients im
LEFT JOIN ingredient_translations it LEFT JOIN ingredient_translations it
ON it.ingredient_id = im.id AND it.lang = $2 ON it.ingredient_id = im.id AND it.lang = $2
LEFT JOIN ingredient_category_translations ict LEFT JOIN ingredient_category_translations ict
@@ -88,7 +88,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
return m, err 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. // Searches both English and translated names for the language in ctx.
// Returns nil, nil when no match is found. // Returns nil, nil when no match is found.
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) { 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 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. // Searches aliases table and translated names for the language in ctx.
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) { func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
if limit <= 0 { 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.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, im.storage_days, im.created_at, im.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases COALESCE(al.aliases, '[]'::json) AS aliases
FROM ingredient_mappings im FROM ingredients im
LEFT JOIN ingredient_translations it LEFT JOIN ingredient_translations it
ON it.ingredient_id = im.id AND it.lang = $3 ON it.ingredient_id = im.id AND it.lang = $3
LEFT JOIN ingredient_category_translations ict 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) rows, err := r.pool.Query(ctx, q, query, limit, lang)
if err != nil { if err != nil {
return nil, fmt.Errorf("search ingredient_mappings: %w", err) return nil, fmt.Errorf("search ingredients: %w", err)
} }
defer rows.Close() defer rows.Close()
return collectMappingsRead(rows) 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) { func (r *Repository) Count(ctx context.Context) (int, error) {
var n int var n int
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil { if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil {
return 0, fmt.Errorf("count ingredient_mappings: %w", err) return 0, fmt.Errorf("count ingredients: %w", err)
} }
return n, nil return n, nil
} }
@@ -165,7 +165,7 @@ func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, li
im.category, im.default_unit, 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.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 im.storage_days, im.created_at, im.updated_at
FROM ingredient_mappings im FROM ingredients im
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM ingredient_translations it SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = im.id AND it.lang = $3 WHERE it.ingredient_id = im.id AND it.lang = $3

View File

@@ -10,10 +10,10 @@ import (
"sync" "sync"
"time" "time"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user" "github.com/food-ai/backend/internal/user"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -33,9 +33,9 @@ type ProductLister interface {
ListForPrompt(ctx context.Context, userID string) ([]string, error) 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 { 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. // Handler handles menu and shopping-list endpoints.
@@ -136,7 +136,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
menuReq.AvailableProducts = products menuReq.AvailableProducts = products
} }
// Generate 7-day plan via OpenAI. // Generate 7-day plan via Gemini.
days, err := h.gemini.GenerateMenu(r.Context(), menuReq) days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
if err != nil { if err != nil {
slog.Error("generate menu", "user_id", userID, "err", err) 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 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 { type savedRef struct {
day int day int
meal int meal int
@@ -184,13 +184,13 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
refs := make([]savedRef, 0, len(days)*3) refs := make([]savedRef, 0, len(days)*3)
for di, day := range days { for di, day := range days {
for mi, meal := range day.Meals { 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 { if err != nil {
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err) slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
writeError(w, http.StatusInternalServerError, "failed to save recipes") writeError(w, http.StatusInternalServerError, "failed to save recipes")
return return
} }
refs = append(refs, savedRef{di, mi, saved.ID}) refs = append(refs, savedRef{di, mi, recipeID})
} }
} }
@@ -420,33 +420,21 @@ func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]Shopp
type key struct{ name, unit string } type key struct{ name, unit string }
totals := map[key]float64{} totals := map[key]float64{}
categories := map[string]string{} // name → category (from meal_type heuristic)
for _, row := range rows { for _, row := range rows {
var ingredients []struct { unit := ""
Name string `json:"name"` if row.UnitCode != nil {
Amount float64 `json:"amount"` unit = *row.UnitCode
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"
}
} }
k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit}
totals[k] += row.Amount
} }
items := make([]ShoppingItem, 0, len(totals)) items := make([]ShoppingItem, 0, len(totals))
for k, amount := range totals { for k, amount := range totals {
items = append(items, ShoppingItem{ items = append(items, ShoppingItem{
Name: k.name, Name: k.name,
Category: categories[k.name], Category: "other",
Amount: amount, Amount: amount,
Unit: k.unit, Unit: k.unit,
Checked: false, Checked: false,
@@ -479,26 +467,70 @@ func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
return req return req
} }
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest { // recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
ingJSON, _ := json.Marshal(r.Ingredients) func recipeToCreateRequest(r gemini.Recipe) dish.CreateRequest {
stepsJSON, _ := json.Marshal(r.Steps) cr := dish.CreateRequest{
tagsJSON, _ := json.Marshal(r.Tags) Name: r.Title,
nutritionJSON, _ := json.Marshal(r.Nutrition)
return savedrecipe.SaveRequest{
Title: r.Title,
Description: r.Description, Description: r.Description,
Cuisine: r.Cuisine, CuisineSlug: mapCuisineSlug(r.Cuisine),
ImageURL: r.ImageURL,
Difficulty: r.Difficulty, Difficulty: r.Difficulty,
PrepTimeMin: r.PrepTimeMin, PrepTimeMin: r.PrepTimeMin,
CookTimeMin: r.CookTimeMin, CookTimeMin: r.CookTimeMin,
Servings: r.Servings, Servings: r.Servings,
ImageURL: r.ImageURL, Calories: r.Nutrition.Calories,
Ingredients: ingJSON, Protein: r.Nutrition.ProteinG,
Steps: stepsJSON, Fat: r.Nutrition.FatG,
Tags: tagsJSON, Carbs: r.Nutrition.CarbsG,
Nutrition: nutritionJSON,
Source: "menu", 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. // resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "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). // 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. // Returns nil, nil when no plan exists for that week.
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) { func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
lang := locale.FromContext(ctx)
const q = ` const q = `
SELECT mp.id, mp.week_start::text, SELECT mp.id, mp.week_start::text,
mi.id, mi.day_of_week, mi.meal_type, 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 FROM menu_plans mp
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id 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 WHERE mp.user_id = $1 AND mp.week_start::text = $2
ORDER BY mi.day_of_week, ORDER BY mi.day_of_week,
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END` 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 { if err != nil {
return nil, fmt.Errorf("get menu by week: %w", err) return nil, fmt.Errorf("get menu by week: %w", err)
} }
@@ -53,12 +64,13 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
itemID, mealType *string itemID, mealType *string
dow *int dow *int
recipeID, title, imageURL *string recipeID, title, imageURL *string
nutritionRaw []byte calPer, protPer, fatPer, carbPer *float64
) )
if err := rows.Scan( if err := rows.Scan(
&planID, &planWeekStart, &planID, &planWeekStart,
&itemID, &dow, &mealType, &itemID, &dow, &mealType,
&recipeID, &title, &imageURL, &nutritionRaw, &recipeID, &title, &imageURL,
&calPer, &protPer, &fatPer, &carbPer,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan menu row: %w", err) 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} slot := MealSlot{ID: *itemID, MealType: *mealType}
if recipeID != nil && title != nil { if recipeID != nil && title != nil {
var nutrition NutritionInfo nutrition := NutritionInfo{
if len(nutritionRaw) > 0 { Calories: derefFloat(calPer),
_ = json.Unmarshal(nutritionRaw, &nutrition) ProteinG: derefFloat(protPer),
FatG: derefFloat(fatPer),
CarbsG: derefFloat(carbPer),
} }
slot.Recipe = &MenuRecipe{ slot.Recipe = &MenuRecipe{
ID: *recipeID, 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. // GetIngredientsByPlan returns all ingredients from all recipes in the plan.
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) { func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
rows, err := r.pool.Query(ctx, ` 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 FROM menu_items mi
JOIN saved_recipes sr ON sr.id = mi.recipe_id JOIN recipes rec ON rec.id = mi.recipe_id
WHERE mi.menu_plan_id = $1`, planID) 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 { if err != nil {
return nil, fmt.Errorf("get ingredients by plan: %w", err) return nil, fmt.Errorf("get ingredients by plan: %w", err)
} }
@@ -268,23 +284,19 @@ func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([
var result []ingredientRow var result []ingredientRow
for rows.Next() { for rows.Next() {
var ingredientsRaw, nutritionRaw []byte var row ingredientRow
var mealType string if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil {
return nil, err return nil, err
} }
result = append(result, ingredientRow{ result = append(result, row)
IngredientsJSON: ingredientsRaw,
NutritionJSON: nutritionRaw,
MealType: mealType,
})
} }
return result, rows.Err() return result, rows.Err()
} }
type ingredientRow struct { type ingredientRow struct {
IngredientsJSON []byte Name string
NutritionJSON []byte Amount float64
UnitCode *string
MealType string MealType string
} }
@@ -304,3 +316,10 @@ func derefStr(s *string) string {
} }
return *s return *s
} }
func derefFloat(f *float64) float64 {
if f == nil {
return 0
}
return *f
}

View File

@@ -6,7 +6,7 @@ import "time"
type Product struct { type Product struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
MappingID *string `json:"mapping_id"` PrimaryIngredientID *string `json:"primary_ingredient_id"`
Name string `json:"name"` Name string `json:"name"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Unit string `json:"unit"` Unit string `json:"unit"`
@@ -20,6 +20,8 @@ type Product struct {
// CreateRequest is the body for POST /products. // CreateRequest is the body for POST /products.
type CreateRequest struct { type CreateRequest struct {
PrimaryIngredientID *string `json:"primary_ingredient_id"`
// Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields.
MappingID *string `json:"mapping_id"` MappingID *string `json:"mapping_id"`
Name string `json:"name"` Name string `json:"name"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`

View File

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

View File

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

View File

@@ -1,28 +1,18 @@
package recipe package recipe
import ( import "time"
"encoding/json"
"time"
)
// Recipe is a recipe record in the database. // Recipe is a cooking variant of a Dish in the catalog.
// Title, Description, Ingredients, and Steps hold the content for the language // It links to a Dish for all presentational data (name, image, cuisine, tags).
// resolved at query time (English by default, or from recipe_translations when
// a matching row exists for the requested language).
type Recipe struct { type Recipe struct {
ID string `json:"id"` ID string `json:"id"`
Source string `json:"source"` // spoonacular | ai | user DishID string `json:"dish_id"`
SpoonacularID *int `json:"spoonacular_id"` Source string `json:"source"` // ai | user | spoonacular
Title string `json:"title"` Difficulty *string `json:"difficulty"`
Description *string `json:"description"`
Cuisine *string `json:"cuisine"`
Difficulty *string `json:"difficulty"` // easy | medium | hard
PrepTimeMin *int `json:"prep_time_min"` PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"` CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"` Servings *int `json:"servings"`
ImageURL *string `json:"image_url"`
CaloriesPerServing *float64 `json:"calories_per_serving"` CaloriesPerServing *float64 `json:"calories_per_serving"`
ProteinPerServing *float64 `json:"protein_per_serving"` ProteinPerServing *float64 `json:"protein_per_serving"`
@@ -30,29 +20,29 @@ type Recipe struct {
CarbsPerServing *float64 `json:"carbs_per_serving"` CarbsPerServing *float64 `json:"carbs_per_serving"`
FiberPerServing *float64 `json:"fiber_per_serving"` FiberPerServing *float64 `json:"fiber_per_serving"`
Ingredients json.RawMessage `json:"ingredients"` // []RecipeIngredient Ingredients []RecipeIngredient `json:"ingredients"`
Steps json.RawMessage `json:"steps"` // []RecipeStep Steps []RecipeStep `json:"steps"`
Tags json.RawMessage `json:"tags"` // []string 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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 { type RecipeIngredient struct {
MappingID *string `json:"mapping_id"` ID string `json:"id"`
IngredientID *string `json:"ingredient_id"`
Name string `json:"name"` Name string `json:"name"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
Unit string `json:"unit"` UnitCode *string `json:"unit_code"`
Optional bool `json:"optional"` 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 { type RecipeStep struct {
Number int `json:"number"` ID string `json:"id"`
StepNumber int `json:"step_number"`
Description string `json:"description"` Description string `json:"description"`
TimerSeconds *int `json:"timer_seconds"` TimerSeconds *int `json:"timer_seconds"`
ImageURL *string `json:"image_url"` ImageURL *string `json:"image_url"`

View File

@@ -2,7 +2,6 @@ package recipe
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -11,7 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "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 { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -21,77 +20,39 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool} return &Repository{pool: pool}
} }
// Upsert inserts or updates a recipe (English canonical content only). // GetByID returns a recipe with its ingredients and steps.
// Conflict is resolved on spoonacular_id. // Text is resolved for the language stored in ctx (English fallback).
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).
// Returns nil, nil if not found. // Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) { func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
lang := locale.FromContext(ctx) lang := locale.FromContext(ctx)
query := `
SELECT r.id, r.source, r.spoonacular_id, const q = `
COALESCE(rt.title, r.title) AS title, SELECT r.id, r.dish_id, r.source, r.difficulty,
COALESCE(rt.description, r.description) AS description, r.prep_time_min, r.cook_time_min, r.servings,
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.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving, r.carbs_per_serving, r.fiber_per_serving,
COALESCE(rt.ingredients, r.ingredients) AS ingredients, rt.notes,
COALESCE(rt.steps, r.steps) AS steps, r.created_at, r.updated_at
r.tags,
r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at
FROM recipes r FROM recipes r
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2 LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
WHERE r.id = $1` WHERE r.id = $1`
row := r.pool.QueryRow(ctx, query, id, lang) row := r.pool.QueryRow(ctx, q, id, lang)
rec, err := scanRecipe(row) rec, err := scanRecipe(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil 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. // Count returns the total number of recipes.
@@ -103,97 +64,79 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
return n, nil return n, nil
} }
// ListMissingTranslation returns Spoonacular recipes that have no translation // loadIngredients fills rec.Ingredients from recipe_ingredients.
// for the given language, ordered by review_count DESC. func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) { rows, err := r.pool.Query(ctx, `
query := ` SELECT ri.id, ri.ingredient_id,
SELECT id, source, spoonacular_id, COALESCE(rit.name, ri.name) AS name,
title, description, ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, FROM recipe_ingredients ri
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, LEFT JOIN recipe_ingredient_translations rit
ingredients, steps, tags, ON rit.ri_id = ri.id AND rit.lang = $2
avg_rating, review_count, created_by, created_at, updated_at WHERE ri.recipe_id = $1
FROM recipes ORDER BY ri.sort_order`, rec.ID, lang)
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)
if err != nil { 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() defer rows.Close()
return collectRecipes(rows)
}
// UpsertTranslation inserts or replaces a recipe translation for a specific language. for rows.Next() {
func (r *Repository) UpsertTranslation( var ing RecipeIngredient
ctx context.Context, if err := rows.Scan(
id, lang string, &ing.ID, &ing.IngredientID, &ing.Name,
title, description *string, &ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder,
ingredients, steps json.RawMessage, ); err != nil {
) error { return fmt.Errorf("scan ingredient: %w", err)
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)
} }
return nil rec.Ingredients = append(rec.Ingredients, ing)
}
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) { func scanRecipe(row pgx.Row) (*Recipe, error) {
var rec Recipe var rec Recipe
var ingredients, steps, tags []byte
err := row.Scan( err := row.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID, &rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
&rec.Title, &rec.Description, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags, &rec.Notes,
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt, &rec.CreatedAt, &rec.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
rec.Ingredients = json.RawMessage(ingredients) rec.Ingredients = []RecipeIngredient{}
rec.Steps = json.RawMessage(steps) rec.Steps = []RecipeStep{}
rec.Tags = json.RawMessage(tags)
return &rec, nil 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()
}

View File

@@ -4,132 +4,11 @@ package recipe
import ( import (
"context" "context"
"encoding/json"
"testing" "testing"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/testutil" "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) { func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) 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) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) repo := NewRepository(pool)
ctx := context.Background() ctx := context.Background()
diff := "easy" _, err := repo.Count(ctx)
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 { if err != nil {
t.Fatalf("upsert recipe %d: %v", i, err) t.Fatalf("count: %v", err)
}
}
missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0)
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)
} }
} }

View File

@@ -36,8 +36,8 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body") writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return return
} }
if req.Title == "" { if req.Title == "" && req.RecipeID == "" {
writeErrorJSON(w, http.StatusBadRequest, "title is required") writeErrorJSON(w, http.StatusBadRequest, "title or recipe_id is required")
return return
} }
@@ -64,9 +64,8 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes") writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
return return
} }
if recipes == nil { if recipes == nil {
recipes = []*SavedRecipe{} recipes = []*UserSavedRecipe{}
} }
writeJSON(w, http.StatusOK, recipes) writeJSON(w, http.StatusOK, recipes)
} }

View File

@@ -1,32 +1,56 @@
package savedrecipe package savedrecipe
import ( import "time"
"encoding/json"
"time"
)
// SavedRecipe is a recipe saved by a specific user. // UserSavedRecipe is a user's bookmark referencing a catalog recipe.
type SavedRecipe struct { // Display fields are populated by joining dishes + recipes.
type UserSavedRecipe struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"-"` UserID string `json:"-"`
Title string `json:"title"` 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"` Description *string `json:"description"`
Cuisine *string `json:"cuisine"` ImageURL *string `json:"image_url"`
CuisineSlug *string `json:"cuisine_slug"`
Tags []string `json:"tags"`
Difficulty *string `json:"difficulty"` Difficulty *string `json:"difficulty"`
PrepTimeMin *int `json:"prep_time_min"` PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"` CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"` Servings *int `json:"servings"`
ImageURL *string `json:"image_url"`
Ingredients json.RawMessage `json:"ingredients"` CaloriesPerServing *float64 `json:"calories_per_serving"`
Steps json.RawMessage `json:"steps"` ProteinPerServing *float64 `json:"protein_per_serving"`
Tags json.RawMessage `json:"tags"` FatPerServing *float64 `json:"fat_per_serving"`
Nutrition json.RawMessage `json:"nutrition_per_serving"` CarbsPerServing *float64 `json:"carbs_per_serving"`
Source string `json:"source"`
SavedAt time.Time `json:"saved_at"` 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. // 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 { type SaveRequest struct {
RecipeID string `json:"recipe_id"` // optional: bookmark existing recipe
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Cuisine string `json:"cuisine"` Cuisine string `json:"cuisine"`
@@ -35,9 +59,18 @@ type SaveRequest struct {
CookTimeMin int `json:"cook_time_min"` CookTimeMin int `json:"cook_time_min"`
Servings int `json:"servings"` Servings int `json:"servings"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
Ingredients json.RawMessage `json:"ingredients"` // Ingredients / Steps / Tags / Nutrition are JSONB for backward compatibility
Steps json.RawMessage `json:"steps"` // with the recommendation flow that sends the full Gemini response.
Tags json.RawMessage `json:"tags"` Ingredients interface{} `json:"ingredients"`
Nutrition json.RawMessage `json:"nutrition_per_serving"` Steps interface{} `json:"steps"`
Tags interface{} `json:"tags"`
Nutrition interface{} `json:"nutrition_per_serving"`
Source string `json:"source"` 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) }

View File

@@ -6,132 +6,244 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// ErrNotFound is returned when a saved recipe does not exist for the given user. // Repository handles persistence for user_saved_recipes.
var ErrNotFound = errors.New("saved recipe not found")
// Repository handles persistence for saved recipes and their translations.
type Repository struct { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
dishRepo *dish.Repository
} }
// NewRepository creates a new Repository. // NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository { func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository {
return &Repository{pool: pool} return &Repository{pool: pool, dishRepo: dishRepo}
} }
// Save persists a recipe for userID and returns the stored record. // Save bookmarks a recipe for the user.
// The canonical content (any language) is stored directly in saved_recipes. // If req.RecipeID is set, that existing catalog recipe is bookmarked.
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) { // Otherwise a new dish + recipe is created from the supplied fields.
const query = ` func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) {
INSERT INTO saved_recipes ( recipeID := req.RecipeID
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`
description := nullableStr(req.Description) if recipeID == "" {
cuisine := nullableStr(req.Cuisine) // Build a dish.CreateRequest from the save body.
difficulty := nullableStr(req.Difficulty) cr := dish.CreateRequest{
imageURL := nullableStr(req.ImageURL) Name: req.Title,
prepTime := nullableInt(req.PrepTimeMin) Description: req.Description,
cookTime := nullableInt(req.CookTimeMin) CuisineSlug: mapCuisineSlug(req.Cuisine),
servings := nullableInt(req.Servings) ImageURL: req.ImageURL,
Source: req.Source,
source := req.Source Difficulty: req.Difficulty,
if source == "" { PrepTimeMin: req.PrepTimeMin,
source = "ai" CookTimeMin: req.CookTimeMin,
Servings: req.Servings,
} }
ingredients := defaultJSONArray(req.Ingredients) // Unmarshal ingredients.
steps := defaultJSONArray(req.Steps) if req.Ingredients != nil {
tags := defaultJSONArray(req.Tags) 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
}
}
}
row := r.pool.QueryRow(ctx, query, // Unmarshal steps.
userID, req.Title, description, cuisine, difficulty, if req.Steps != nil {
prepTime, cookTime, servings, imageURL, switch v := req.Steps.(type) {
ingredients, steps, tags, req.Nutrition, source, 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)
}
}
// 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`
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. // List returns all bookmarked recipes for userID ordered by saved_at DESC.
// Text content (title, description, ingredients, steps) is resolved for the func (r *Repository) List(ctx context.Context, userID string) ([]*UserSavedRecipe, error) {
// 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) {
lang := locale.FromContext(ctx) lang := locale.FromContext(ctx)
const query = ` const q = `
SELECT sr.id, sr.user_id, SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
COALESCE(srt.title, sr.title) AS title, COALESCE(dt.name, d.name) AS dish_name,
COALESCE(srt.description, sr.description) AS description, COALESCE(dt.description, d.description) AS description,
sr.cuisine, sr.difficulty, d.image_url, d.cuisine_slug,
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
COALESCE(srt.ingredients, sr.ingredients) AS ingredients, r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
COALESCE(srt.steps, sr.steps) AS steps, FROM user_saved_recipes usr
sr.tags, sr.nutrition, sr.source, sr.saved_at JOIN recipes r ON r.id = usr.recipe_id
FROM saved_recipes sr JOIN dishes d ON d.id = r.dish_id
LEFT JOIN saved_recipe_translations srt LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
ON srt.saved_recipe_id = sr.id AND srt.lang = $2 WHERE usr.user_id = $1
WHERE sr.user_id = $1 ORDER BY usr.saved_at DESC`
ORDER BY sr.saved_at DESC`
rows, err := r.pool.Query(ctx, query, userID, lang) rows, err := r.pool.Query(ctx, q, userID, lang)
if err != nil { if err != nil {
return nil, fmt.Errorf("list saved recipes: %w", err) return nil, fmt.Errorf("list saved recipes: %w", err)
} }
defer rows.Close() defer rows.Close()
var result []*SavedRecipe var result []*UserSavedRecipe
for rows.Next() { for rows.Next() {
rec, err := scanRow(rows) rec, err := scanUSR(rows)
if err != nil { if err != nil {
return nil, fmt.Errorf("scan saved recipe: %w", err) 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) result = append(result, rec)
} }
return result, rows.Err() return result, rows.Err()
} }
// GetByID returns the saved recipe with id for userID, or nil if not found. // GetByID returns a bookmarked recipe by its bookmark ID for userID.
// Text content is resolved for the language stored in ctx. // Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) { func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) {
lang := locale.FromContext(ctx) lang := locale.FromContext(ctx)
const query = ` const q = `
SELECT sr.id, sr.user_id, SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
COALESCE(srt.title, sr.title) AS title, COALESCE(dt.name, d.name) AS dish_name,
COALESCE(srt.description, sr.description) AS description, COALESCE(dt.description, d.description) AS description,
sr.cuisine, sr.difficulty, d.image_url, d.cuisine_slug,
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
COALESCE(srt.ingredients, sr.ingredients) AS ingredients, r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
COALESCE(srt.steps, sr.steps) AS steps, FROM user_saved_recipes usr
sr.tags, sr.nutrition, sr.source, sr.saved_at JOIN recipes r ON r.id = usr.recipe_id
FROM saved_recipes sr JOIN dishes d ON d.id = r.dish_id
LEFT JOIN saved_recipe_translations srt LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
ON srt.saved_recipe_id = sr.id AND srt.lang = $3 WHERE usr.id = $1 AND usr.user_id = $2`
WHERE sr.id = $1 AND sr.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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil 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. // Delete removes a bookmark.
// Returns ErrNotFound if the record does not exist.
func (r *Repository) Delete(ctx context.Context, userID, id string) error { func (r *Repository) Delete(ctx context.Context, userID, id string) error {
tag, err := r.pool.Exec(ctx, tag, err := r.pool.Exec(ctx,
`DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`, `DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID)
id, userID,
)
if err != nil { if err != nil {
return fmt.Errorf("delete saved recipe: %w", err) 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 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 --- // --- 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 Scan(dest ...any) error
} }
func scanRow(s scannable) (*SavedRecipe, error) { func scanUSR(s rowScanner) (*UserSavedRecipe, error) {
var rec SavedRecipe var r UserSavedRecipe
var ingredients, steps, tags, nutrition []byte
err := s.Scan( err := s.Scan(
&rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty, &r.ID, &r.UserID, &r.RecipeID, &r.SavedAt,
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug,
&ingredients, &steps, &tags, &nutrition, &r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings,
&rec.Source, &rec.SavedAt, &r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing,
) )
if err != nil { return &r, err
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
} }
func nullableStr(s string) *string { // mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
if s == "" { // Falls back to "other".
return nil 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 { func strVal(v interface{}) string {
if n <= 0 { if s, ok := v.(string); ok {
return nil return s
} }
return &n return ""
} }
func defaultJSONArray(raw json.RawMessage) json.RawMessage { func floatVal(v interface{}) float64 {
if len(raw) == 0 { switch n := v.(type) {
return json.RawMessage(`[]`) case float64:
return n
case int:
return float64(n)
} }
return raw return 0
} }

View File

@@ -5,12 +5,16 @@ import (
"net/http" "net/http"
"github.com/food-ai/backend/internal/auth" "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/diary"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/language" "github.com/food-ai/backend/internal/language"
"github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware" "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/units"
"github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recognition" "github.com/food-ai/backend/internal/recognition"
@@ -33,6 +37,8 @@ func NewRouter(
menuHandler *menu.Handler, menuHandler *menu.Handler,
diaryHandler *diary.Handler, diaryHandler *diary.Handler,
homeHandler *home.Handler, homeHandler *home.Handler,
dishHandler *dish.Handler,
recipeHandler *recipe.Handler,
authMiddleware func(http.Handler) http.Handler, authMiddleware func(http.Handler) http.Handler,
allowedOrigins []string, allowedOrigins []string,
) *chi.Mux { ) *chi.Mux {
@@ -49,6 +55,8 @@ func NewRouter(
r.Get("/health", healthCheck(pool)) r.Get("/health", healthCheck(pool))
r.Get("/languages", language.List) r.Get("/languages", language.List)
r.Get("/units", units.List) r.Get("/units", units.List)
r.Get("/cuisines", cuisine.List)
r.Get("/tags", tag.List)
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", authHandler.Login) r.Post("/login", authHandler.Login)
r.Post("/refresh", authHandler.Refresh) r.Post("/refresh", authHandler.Refresh)
@@ -81,6 +89,13 @@ func NewRouter(
r.Delete("/{id}", productHandler.Delete) 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.Route("/menu", func(r chi.Router) {
r.Get("/", menuHandler.GetMenu) r.Get("/", menuHandler.GetMenu)
r.Put("/items/{id}", menuHandler.UpdateMenuItem) r.Put("/items/{id}", menuHandler.UpdateMenuItem)

View File

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

View File

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

View File

@@ -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 05 with the timestamp (positions 16 in 1-indexed bytea).
uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);
-- Set version nibble (bits 4851) 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 (6465) 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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '')));

View File

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

View File

@@ -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);

View File

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

View File

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

View File

@@ -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<List<Cuisine>> fetchCuisines() async {
final data = await _api.get('/cuisines');
final List<dynamic> items = data['cuisines'] as List;
return items
.map((e) => Cuisine.fromJson(e as Map<String, dynamic>))
.toList();
}
}
final cuisineRepositoryProvider = Provider<CuisineRepository>(
(ref) => CuisineRepository(ref.watch(apiClientProvider)),
);

View File

@@ -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<List<Tag>> fetchTags() async {
final data = await _api.get('/tags');
final List<dynamic> items = data['tags'] as List;
return items
.map((e) => Tag.fromJson(e as Map<String, dynamic>))
.toList();
}
}
final tagRepositoryProvider = Provider<TagRepository>(
(ref) => TagRepository(ref.watch(apiClientProvider)),
);

View File

@@ -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<List<Cuisine>>((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<Map<String, String>>((ref) async {
final cuisines = await ref.watch(cuisinesProvider.future);
return {for (final c in cuisines) c.slug: c.name};
});

View File

@@ -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<List<Tag>>((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<Map<String, String>>((ref) async {
final tags = await ref.watch(tagsProvider.future);
return {for (final t in tags) t.slug: t.name};
});

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/locale/unit_provider.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../shared/models/recipe.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? prepTimeMin;
final int? cookTimeMin; final int? cookTimeMin;
final String? difficulty; final String? difficulty;
@@ -216,8 +218,9 @@ class _MetaChips extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0); final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
final cuisineNames = ref.watch(cuisineNamesProvider).valueOrNull ?? {};
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 4, runSpacing: 4,
@@ -227,7 +230,9 @@ class _MetaChips extends StatelessWidget {
if (difficulty != null) if (difficulty != null)
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)), _Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
if (cuisine != null) if (cuisine != null)
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)), _Chip(
icon: Icons.public,
label: cuisineNames[cuisine!] ?? cuisine!),
if (servings != null) if (servings != null)
_Chip(icon: Icons.people, label: '$servings порц.'), _Chip(icon: Icons.people, label: '$servings порц.'),
], ],
@@ -240,15 +245,6 @@ class _MetaChips extends StatelessWidget {
'hard' => 'Сложно', 'hard' => 'Сложно',
_ => d, _ => d,
}; };
String _cuisineLabel(String c) => switch (c) {
'russian' => 'Русская',
'asian' => 'Азиатская',
'european' => 'Европейская',
'mediterranean' => 'Средиземноморская',
'american' => 'Американская',
_ => 'Другая',
};
} }
class _Chip extends StatelessWidget { class _Chip extends StatelessWidget {
@@ -347,20 +343,24 @@ class _NutCell extends StatelessWidget {
); );
} }
class _TagsRow extends StatelessWidget { class _TagsRow extends ConsumerWidget {
final List<String> tags; final List<String> tags;
const _TagsRow({required this.tags}); const _TagsRow({required this.tags});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final tagNames = ref.watch(tagNamesProvider).valueOrNull ?? {};
return Wrap( return Wrap(
spacing: 6, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: tags children: tags
.map( .map(
(t) => Chip( (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), backgroundColor: AppColors.primary.withValues(alpha: 0.15),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@@ -402,7 +402,7 @@ class _IngredientsSection extends ConsumerWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: Text(ing.name)), Expanded(child: Text(ing.name)),
Text( 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( style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13), color: AppColors.textSecondary, fontSize: 13),
), ),

View File

@@ -0,0 +1,13 @@
class Cuisine {
final String slug;
final String name;
const Cuisine({required this.slug, required this.name});
factory Cuisine.fromJson(Map<String, dynamic> json) {
return Cuisine(
slug: json['slug'] as String? ?? '',
name: json['name'] as String? ?? '',
);
}
}

View File

@@ -9,7 +9,9 @@ class DiaryEntry {
final double? fatG; final double? fatG;
final double? carbsG; final double? carbsG;
final String source; final String source;
final String? dishId;
final String? recipeId; final String? recipeId;
final double? portionG;
const DiaryEntry({ const DiaryEntry({
required this.id, required this.id,
@@ -22,7 +24,9 @@ class DiaryEntry {
this.fatG, this.fatG,
this.carbsG, this.carbsG,
required this.source, required this.source,
this.dishId,
this.recipeId, this.recipeId,
this.portionG,
}); });
factory DiaryEntry.fromJson(Map<String, dynamic> json) { factory DiaryEntry.fromJson(Map<String, dynamic> json) {
@@ -37,7 +41,9 @@ class DiaryEntry {
fatG: (json['fat_g'] as num?)?.toDouble(), fatG: (json['fat_g'] as num?)?.toDouble(),
carbsG: (json['carbs_g'] as num?)?.toDouble(), carbsG: (json['carbs_g'] as num?)?.toDouble(),
source: json['source'] as String? ?? 'manual', source: json['source'] as String? ?? 'manual',
dishId: json['dish_id'] as String?,
recipeId: json['recipe_id'] as String?, recipeId: json['recipe_id'] as String?,
portionG: (json['portion_g'] as num?)?.toDouble(),
); );
} }

View File

@@ -59,14 +59,25 @@ class Recipe {
class RecipeIngredient { class RecipeIngredient {
final String name; final String name;
final double amount; final double amount;
/// Unit from Gemini recommendations (free-form string).
@JsonKey(defaultValue: '')
final String unit; final String unit;
/// Unit code from the DB-backed saved recipes endpoint.
@JsonKey(name: 'unit_code')
final String? unitCode;
const RecipeIngredient({ const RecipeIngredient({
required this.name, required this.name,
required this.amount, 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<String, dynamic> json) => factory RecipeIngredient.fromJson(Map<String, dynamic> json) =>
_$RecipeIngredientFromJson(json); _$RecipeIngredientFromJson(json);
Map<String, dynamic> toJson() => _$RecipeIngredientToJson(this); Map<String, dynamic> toJson() => _$RecipeIngredientToJson(this);

View File

@@ -55,7 +55,8 @@ RecipeIngredient _$RecipeIngredientFromJson(Map<String, dynamic> json) =>
RecipeIngredient( RecipeIngredient(
name: json['name'] as String, name: json['name'] as String,
amount: (json['amount'] as num).toDouble(), amount: (json['amount'] as num).toDouble(),
unit: json['unit'] as String, unit: json['unit'] as String? ?? '',
unitCode: json['unit_code'] as String?,
); );
Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) => Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
@@ -63,6 +64,7 @@ Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
'name': instance.name, 'name': instance.name,
'amount': instance.amount, 'amount': instance.amount,
'unit': instance.unit, 'unit': instance.unit,
'unit_code': instance.unitCode,
}; };
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep( RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(

View File

@@ -1,47 +1,36 @@
import 'package:json_annotation/json_annotation.dart';
import 'recipe.dart'; import 'recipe.dart';
part 'saved_recipe.g.dart'; /// A user's bookmarked recipe. Display fields are joined from dishes + recipes.
@JsonSerializable(explicitToJson: true)
class SavedRecipe { class SavedRecipe {
final String id; final String id;
final String recipeId;
final String title; final String title;
final String? description; final String? description;
/// Mapped from cuisine_slug in the API response.
final String? cuisine; final String? cuisine;
final String? difficulty; final String? difficulty;
@JsonKey(name: 'prep_time_min')
final int? prepTimeMin; final int? prepTimeMin;
@JsonKey(name: 'cook_time_min')
final int? cookTimeMin; final int? cookTimeMin;
final int? servings; final int? servings;
@JsonKey(name: 'image_url')
final String? imageUrl; final String? imageUrl;
@JsonKey(defaultValue: [])
final List<RecipeIngredient> ingredients; final List<RecipeIngredient> ingredients;
@JsonKey(defaultValue: [])
final List<RecipeStep> steps; final List<RecipeStep> steps;
@JsonKey(defaultValue: [])
final List<String> tags; final List<String> tags;
@JsonKey(name: 'nutrition_per_serving')
final NutritionInfo? nutrition;
final String source;
@JsonKey(name: 'saved_at')
final DateTime savedAt; final DateTime savedAt;
// Individual nutrition columns (nullable).
final double? caloriesPerServing;
final double? proteinPerServing;
final double? fatPerServing;
final double? carbsPerServing;
const SavedRecipe({ const SavedRecipe({
required this.id, required this.id,
required this.recipeId,
required this.title, required this.title,
this.description, this.description,
this.cuisine, this.cuisine,
@@ -53,12 +42,81 @@ class SavedRecipe {
this.ingredients = const [], this.ingredients = const [],
this.steps = const [], this.steps = const [],
this.tags = const [], this.tags = const [],
this.nutrition,
required this.source,
required this.savedAt, required this.savedAt,
this.caloriesPerServing,
this.proteinPerServing,
this.fatPerServing,
this.carbsPerServing,
}); });
factory SavedRecipe.fromJson(Map<String, dynamic> json) => /// Builds a [NutritionInfo] from the individual per-serving columns,
_$SavedRecipeFromJson(json); /// or null when no calorie data is available.
Map<String, dynamic> toJson() => _$SavedRecipeToJson(this); 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<String, dynamic> 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<dynamic>?)
?.map((e) =>
RecipeIngredient.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
steps: (json['steps'] as List<dynamic>?)
?.map(
(e) => RecipeStep.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
tags: (json['tags'] as List<dynamic>?)
?.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<String, dynamic> 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,
};
} }

View File

@@ -1,57 +1,3 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// This file is intentionally empty.
part of 'saved_recipe.dart'; // saved_recipe.dart now uses a manually written fromJson/toJson.
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SavedRecipe _$SavedRecipeFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => RecipeIngredient.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
steps:
(json['steps'] as List<dynamic>?)
?.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
nutrition: json['nutrition_per_serving'] == null
? null
: NutritionInfo.fromJson(
json['nutrition_per_serving'] as Map<String, dynamic>,
),
source: json['source'] as String,
savedAt: DateTime.parse(json['saved_at'] as String),
);
Map<String, dynamic> _$SavedRecipeToJson(SavedRecipe instance) =>
<String, dynamic>{
'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(),
};

View File

@@ -0,0 +1,13 @@
class Tag {
final String slug;
final String name;
const Tag({required this.slug, required this.name});
factory Tag.fromJson(Map<String, dynamic> json) {
return Tag(
slug: json['slug'] as String? ?? '',
name: json['name'] as String? ?? '',
);
}
}