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:
@@ -12,8 +12,10 @@ import (
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/config"
|
||||
"github.com/food-ai/backend/internal/cuisine"
|
||||
"github.com/food-ai/backend/internal/database"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
@@ -23,10 +25,12 @@ import (
|
||||
"github.com/food-ai/backend/internal/units"
|
||||
"github.com/food-ai/backend/internal/pexels"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/server"
|
||||
"github.com/food-ai/backend/internal/tag"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
@@ -84,6 +88,16 @@ func run() error {
|
||||
}
|
||||
slog.Info("units loaded", "count", len(units.Records))
|
||||
|
||||
if err := cuisine.LoadFromDB(ctx, pool); err != nil {
|
||||
return fmt.Errorf("load cuisines: %w", err)
|
||||
}
|
||||
slog.Info("cuisines loaded", "count", len(cuisine.Records))
|
||||
|
||||
if err := tag.LoadFromDB(ctx, pool); err != nil {
|
||||
return fmt.Errorf("load tags: %w", err)
|
||||
}
|
||||
slog.Info("tags loaded", "count", len(tag.Records))
|
||||
|
||||
// Firebase auth
|
||||
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
||||
if err != nil {
|
||||
@@ -123,13 +137,21 @@ func run() error {
|
||||
// Recommendation domain
|
||||
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
|
||||
|
||||
// Dish domain
|
||||
dishRepo := dish.NewRepository(pool)
|
||||
dishHandler := dish.NewHandler(dishRepo)
|
||||
|
||||
// Recipe domain
|
||||
recipeRepo := recipe.NewRepository(pool)
|
||||
recipeHandler := recipe.NewHandler(recipeRepo)
|
||||
|
||||
// Saved recipes domain
|
||||
savedRecipeRepo := savedrecipe.NewRepository(pool)
|
||||
savedRecipeRepo := savedrecipe.NewRepository(pool, dishRepo)
|
||||
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
|
||||
|
||||
// Menu domain
|
||||
menuRepo := menu.NewRepository(pool)
|
||||
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, savedRecipeRepo)
|
||||
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, dishRepo)
|
||||
|
||||
// Diary domain
|
||||
diaryRepo := diary.NewRepository(pool)
|
||||
@@ -151,6 +173,8 @@ func run() error {
|
||||
menuHandler,
|
||||
diaryHandler,
|
||||
homeHandler,
|
||||
dishHandler,
|
||||
recipeHandler,
|
||||
authMW,
|
||||
cfg.AllowedOrigins,
|
||||
)
|
||||
|
||||
28
backend/internal/cuisine/handler.go
Normal file
28
backend/internal/cuisine/handler.go
Normal 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})
|
||||
}
|
||||
80
backend/internal/cuisine/registry.go
Normal file
80
backend/internal/cuisine/registry.go
Normal 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
|
||||
}
|
||||
@@ -14,20 +14,24 @@ type Entry struct {
|
||||
FatG *float64 `json:"fat_g,omitempty"`
|
||||
CarbsG *float64 `json:"carbs_g,omitempty"`
|
||||
Source string `json:"source"`
|
||||
DishID *string `json:"dish_id,omitempty"`
|
||||
RecipeID *string `json:"recipe_id,omitempty"`
|
||||
PortionG *float64 `json:"portion_g,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateRequest is the body for POST /diary.
|
||||
type CreateRequest struct {
|
||||
Date string `json:"date"`
|
||||
MealType string `json:"meal_type"`
|
||||
Name string `json:"name"`
|
||||
Portions float64 `json:"portions"`
|
||||
Date string `json:"date"`
|
||||
MealType string `json:"meal_type"`
|
||||
Name string `json:"name"`
|
||||
Portions float64 `json:"portions"`
|
||||
Calories *float64 `json:"calories"`
|
||||
ProteinG *float64 `json:"protein_g"`
|
||||
FatG *float64 `json:"fat_g"`
|
||||
CarbsG *float64 `json:"carbs_g"`
|
||||
Source string `json:"source"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
Source string `json:"source"`
|
||||
DishID *string `json:"dish_id"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
PortionG *float64 `json:"portion_g"`
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, date::text, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g,
|
||||
source, recipe_id, created_at
|
||||
source, dish_id, recipe_id, portion_g, created_at
|
||||
FROM meal_diary
|
||||
WHERE user_id = $1 AND date = $2::date
|
||||
ORDER BY created_at ASC`, userID, date)
|
||||
@@ -60,13 +60,13 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
||||
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g, source, recipe_id)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, date::text, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g, source, recipe_id, created_at`,
|
||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`,
|
||||
userID, req.Date, req.MealType, req.Name, portions,
|
||||
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
|
||||
source, req.RecipeID,
|
||||
source, req.DishID, req.RecipeID, req.PortionG,
|
||||
)
|
||||
return scanEntry(row)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func scanEntry(s scannable) (*Entry, error) {
|
||||
err := s.Scan(
|
||||
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
|
||||
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
|
||||
&e.Source, &e.RecipeID, &e.CreatedAt,
|
||||
&e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
|
||||
67
backend/internal/dish/handler.go
Normal file
67
backend/internal/dish/handler.go
Normal 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)
|
||||
}
|
||||
96
backend/internal/dish/model.go
Normal file
96
backend/internal/dish/model.go
Normal 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"`
|
||||
}
|
||||
370
backend/internal/dish/repository.go
Normal file
370
backend/internal/dish/repository.go
Normal 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
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for ingredient_mappings and their translations.
|
||||
// Repository handles persistence for ingredients and their translations.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -21,11 +21,11 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates an ingredient mapping (English canonical content).
|
||||
// Upsert inserts or updates an ingredient (English canonical content).
|
||||
// Conflict is resolved on canonical_name.
|
||||
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
||||
query := `
|
||||
INSERT INTO ingredient_mappings (
|
||||
INSERT INTO ingredients (
|
||||
canonical_name,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
@@ -54,7 +54,7 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
|
||||
return scanMappingWrite(row)
|
||||
}
|
||||
|
||||
// GetByID returns an ingredient mapping by UUID.
|
||||
// GetByID returns an ingredient by UUID.
|
||||
// CanonicalName and aliases are resolved for the language stored in ctx.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
||||
@@ -68,7 +68,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
|
||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||
im.storage_days, im.created_at, im.updated_at,
|
||||
COALESCE(al.aliases, '[]'::json) AS aliases
|
||||
FROM ingredient_mappings im
|
||||
FROM ingredients im
|
||||
LEFT JOIN ingredient_translations it
|
||||
ON it.ingredient_id = im.id AND it.lang = $2
|
||||
LEFT JOIN ingredient_category_translations ict
|
||||
@@ -88,7 +88,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
|
||||
return m, err
|
||||
}
|
||||
|
||||
// FuzzyMatch finds the single best matching ingredient mapping for a given name.
|
||||
// FuzzyMatch finds the single best matching ingredient for a given name.
|
||||
// Searches both English and translated names for the language in ctx.
|
||||
// Returns nil, nil when no match is found.
|
||||
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
||||
@@ -102,7 +102,7 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
// Search finds ingredient mappings matching the query string.
|
||||
// Search finds ingredients matching the query string.
|
||||
// Searches aliases table and translated names for the language in ctx.
|
||||
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
||||
if limit <= 0 {
|
||||
@@ -118,7 +118,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In
|
||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||
im.storage_days, im.created_at, im.updated_at,
|
||||
COALESCE(al.aliases, '[]'::json) AS aliases
|
||||
FROM ingredient_mappings im
|
||||
FROM ingredients im
|
||||
LEFT JOIN ingredient_translations it
|
||||
ON it.ingredient_id = im.id AND it.lang = $3
|
||||
LEFT JOIN ingredient_category_translations ict
|
||||
@@ -142,17 +142,17 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, query, limit, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search ingredient_mappings: %w", err)
|
||||
return nil, fmt.Errorf("search ingredients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectMappingsRead(rows)
|
||||
}
|
||||
|
||||
// Count returns the total number of ingredient mappings.
|
||||
// Count returns the total number of ingredients.
|
||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count ingredient_mappings: %w", err)
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count ingredients: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, li
|
||||
im.category, im.default_unit,
|
||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||
im.storage_days, im.created_at, im.updated_at
|
||||
FROM ingredient_mappings im
|
||||
FROM ingredients im
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ingredient_translations it
|
||||
WHERE it.ingredient_id = im.id AND it.lang = $3
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -33,9 +33,9 @@ type ProductLister interface {
|
||||
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
// RecipeSaver persists a single recipe and returns the stored record.
|
||||
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
|
||||
type RecipeSaver interface {
|
||||
Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error)
|
||||
Create(ctx context.Context, req dish.CreateRequest) (string, error)
|
||||
}
|
||||
|
||||
// Handler handles menu and shopping-list endpoints.
|
||||
@@ -136,7 +136,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
menuReq.AvailableProducts = products
|
||||
}
|
||||
|
||||
// Generate 7-day plan via OpenAI.
|
||||
// Generate 7-day plan via Gemini.
|
||||
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
|
||||
if err != nil {
|
||||
slog.Error("generate menu", "user_id", userID, "err", err)
|
||||
@@ -175,7 +175,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
|
||||
}
|
||||
|
||||
// Save all 21 recipes to saved_recipes.
|
||||
// Persist all 21 recipes as dish+recipe rows.
|
||||
type savedRef struct {
|
||||
day int
|
||||
meal int
|
||||
@@ -184,13 +184,13 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
refs := make([]savedRef, 0, len(days)*3)
|
||||
for di, day := range days {
|
||||
for mi, meal := range day.Meals {
|
||||
saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe))
|
||||
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
|
||||
if err != nil {
|
||||
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save recipes")
|
||||
return
|
||||
}
|
||||
refs = append(refs, savedRef{di, mi, saved.ID})
|
||||
refs = append(refs, savedRef{di, mi, recipeID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,37 +420,25 @@ func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]Shopp
|
||||
|
||||
type key struct{ name, unit string }
|
||||
totals := map[key]float64{}
|
||||
categories := map[string]string{} // name → category (from meal_type heuristic)
|
||||
|
||||
for _, row := range rows {
|
||||
var ingredients []struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
if len(row.IngredientsJSON) > 0 {
|
||||
if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, ing := range ingredients {
|
||||
k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit}
|
||||
totals[k] += ing.Amount
|
||||
if _, ok := categories[k.name]; !ok {
|
||||
categories[k.name] = "other"
|
||||
}
|
||||
unit := ""
|
||||
if row.UnitCode != nil {
|
||||
unit = *row.UnitCode
|
||||
}
|
||||
k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit}
|
||||
totals[k] += row.Amount
|
||||
}
|
||||
|
||||
items := make([]ShoppingItem, 0, len(totals))
|
||||
for k, amount := range totals {
|
||||
items = append(items, ShoppingItem{
|
||||
Name: k.name,
|
||||
Category: categories[k.name],
|
||||
Amount: amount,
|
||||
Unit: k.unit,
|
||||
Checked: false,
|
||||
InStock: 0,
|
||||
Name: k.name,
|
||||
Category: "other",
|
||||
Amount: amount,
|
||||
Unit: k.unit,
|
||||
Checked: false,
|
||||
InStock: 0,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -479,26 +467,70 @@ func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
|
||||
return req
|
||||
}
|
||||
|
||||
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest {
|
||||
ingJSON, _ := json.Marshal(r.Ingredients)
|
||||
stepsJSON, _ := json.Marshal(r.Steps)
|
||||
tagsJSON, _ := json.Marshal(r.Tags)
|
||||
nutritionJSON, _ := json.Marshal(r.Nutrition)
|
||||
return savedrecipe.SaveRequest{
|
||||
Title: r.Title,
|
||||
// recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
|
||||
func recipeToCreateRequest(r gemini.Recipe) dish.CreateRequest {
|
||||
cr := dish.CreateRequest{
|
||||
Name: r.Title,
|
||||
Description: r.Description,
|
||||
Cuisine: r.Cuisine,
|
||||
CuisineSlug: mapCuisineSlug(r.Cuisine),
|
||||
ImageURL: r.ImageURL,
|
||||
Difficulty: r.Difficulty,
|
||||
PrepTimeMin: r.PrepTimeMin,
|
||||
CookTimeMin: r.CookTimeMin,
|
||||
Servings: r.Servings,
|
||||
ImageURL: r.ImageURL,
|
||||
Ingredients: ingJSON,
|
||||
Steps: stepsJSON,
|
||||
Tags: tagsJSON,
|
||||
Nutrition: nutritionJSON,
|
||||
Calories: r.Nutrition.Calories,
|
||||
Protein: r.Nutrition.ProteinG,
|
||||
Fat: r.Nutrition.FatG,
|
||||
Carbs: r.Nutrition.CarbsG,
|
||||
Source: "menu",
|
||||
}
|
||||
for _, ing := range r.Ingredients {
|
||||
cr.Ingredients = append(cr.Ingredients, dish.IngredientInput{
|
||||
Name: ing.Name,
|
||||
Amount: ing.Amount,
|
||||
Unit: ing.Unit,
|
||||
})
|
||||
}
|
||||
for _, s := range r.Steps {
|
||||
cr.Steps = append(cr.Steps, dish.StepInput{
|
||||
Number: s.Number,
|
||||
Description: s.Description,
|
||||
TimerSeconds: s.TimerSeconds,
|
||||
})
|
||||
}
|
||||
cr.Tags = append(cr.Tags, r.Tags...)
|
||||
return cr
|
||||
}
|
||||
|
||||
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||
// Falls back to "other".
|
||||
func mapCuisineSlug(cuisine string) string {
|
||||
known := map[string]string{
|
||||
"russian": "russian",
|
||||
"italian": "italian",
|
||||
"french": "french",
|
||||
"chinese": "chinese",
|
||||
"japanese": "japanese",
|
||||
"korean": "korean",
|
||||
"mexican": "mexican",
|
||||
"mediterranean": "mediterranean",
|
||||
"indian": "indian",
|
||||
"thai": "thai",
|
||||
"american": "american",
|
||||
"georgian": "georgian",
|
||||
"spanish": "spanish",
|
||||
"german": "german",
|
||||
"middle_eastern": "middle_eastern",
|
||||
"turkish": "turkish",
|
||||
"greek": "greek",
|
||||
"vietnamese": "vietnamese",
|
||||
"asian": "other",
|
||||
"european": "other",
|
||||
}
|
||||
if s, ok := known[cuisine]; ok {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -27,18 +28,28 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
|
||||
// Returns nil, nil when no plan exists for that week.
|
||||
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
|
||||
const q = `
|
||||
SELECT mp.id, mp.week_start::text,
|
||||
mi.id, mi.day_of_week, mi.meal_type,
|
||||
sr.id, sr.title, COALESCE(sr.image_url, ''), sr.nutrition
|
||||
rec.id,
|
||||
COALESCE(dt.name, d.name),
|
||||
COALESCE(d.image_url, ''),
|
||||
rec.calories_per_serving,
|
||||
rec.protein_per_serving,
|
||||
rec.fat_per_serving,
|
||||
rec.carbs_per_serving
|
||||
FROM menu_plans mp
|
||||
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
||||
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
||||
LEFT JOIN recipes rec ON rec.id = mi.recipe_id
|
||||
LEFT JOIN dishes d ON d.id = rec.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||
WHERE mp.user_id = $1 AND mp.week_start::text = $2
|
||||
ORDER BY mi.day_of_week,
|
||||
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, userID, weekStart)
|
||||
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get menu by week: %w", err)
|
||||
}
|
||||
@@ -49,16 +60,17 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
planID, planWeekStart string
|
||||
itemID, mealType *string
|
||||
dow *int
|
||||
recipeID, title, imageURL *string
|
||||
nutritionRaw []byte
|
||||
planID, planWeekStart string
|
||||
itemID, mealType *string
|
||||
dow *int
|
||||
recipeID, title, imageURL *string
|
||||
calPer, protPer, fatPer, carbPer *float64
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&planID, &planWeekStart,
|
||||
&itemID, &dow, &mealType,
|
||||
&recipeID, &title, &imageURL, &nutritionRaw,
|
||||
&recipeID, &title, &imageURL,
|
||||
&calPer, &protPer, &fatPer, &carbPer,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan menu row: %w", err)
|
||||
}
|
||||
@@ -79,9 +91,11 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
||||
|
||||
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
||||
if recipeID != nil && title != nil {
|
||||
var nutrition NutritionInfo
|
||||
if len(nutritionRaw) > 0 {
|
||||
_ = json.Unmarshal(nutritionRaw, &nutrition)
|
||||
nutrition := NutritionInfo{
|
||||
Calories: derefFloat(calPer),
|
||||
ProteinG: derefFloat(protPer),
|
||||
FatG: derefFloat(fatPer),
|
||||
CarbsG: derefFloat(carbPer),
|
||||
}
|
||||
slot.Recipe = &MenuRecipe{
|
||||
ID: *recipeID,
|
||||
@@ -257,10 +271,12 @@ func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart stri
|
||||
// GetIngredientsByPlan returns all ingredients from all recipes in the plan.
|
||||
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT sr.ingredients, sr.nutrition, mi.meal_type
|
||||
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type
|
||||
FROM menu_items mi
|
||||
JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
||||
WHERE mi.menu_plan_id = $1`, planID)
|
||||
JOIN recipes rec ON rec.id = mi.recipe_id
|
||||
JOIN recipe_ingredients ri ON ri.recipe_id = rec.id
|
||||
WHERE mi.menu_plan_id = $1
|
||||
ORDER BY ri.sort_order`, planID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ingredients by plan: %w", err)
|
||||
}
|
||||
@@ -268,24 +284,20 @@ func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([
|
||||
|
||||
var result []ingredientRow
|
||||
for rows.Next() {
|
||||
var ingredientsRaw, nutritionRaw []byte
|
||||
var mealType string
|
||||
if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil {
|
||||
var row ingredientRow
|
||||
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ingredientRow{
|
||||
IngredientsJSON: ingredientsRaw,
|
||||
NutritionJSON: nutritionRaw,
|
||||
MealType: mealType,
|
||||
})
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type ingredientRow struct {
|
||||
IngredientsJSON []byte
|
||||
NutritionJSON []byte
|
||||
MealType string
|
||||
Name string
|
||||
Amount float64
|
||||
UnitCode *string
|
||||
MealType string
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -304,3 +316,10 @@ func derefStr(s *string) string {
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func derefFloat(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
@@ -4,28 +4,30 @@ import "time"
|
||||
|
||||
// Product is a user's food item in their pantry.
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
MappingID *string `json:"mapping_id"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
ExpiringSoon bool `json:"expiring_soon"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
PrimaryIngredientID *string `json:"primary_ingredient_id"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
ExpiringSoon bool `json:"expiring_soon"`
|
||||
}
|
||||
|
||||
// CreateRequest is the body for POST /products.
|
||||
type CreateRequest struct {
|
||||
MappingID *string `json:"mapping_id"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
PrimaryIngredientID *string `json:"primary_ingredient_id"`
|
||||
// Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields.
|
||||
MappingID *string `json:"mapping_id"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the body for PUT /products/{id}.
|
||||
|
||||
@@ -25,7 +25,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
|
||||
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE),
|
||||
// which prevents it from being used as a stored generated column.
|
||||
const selectCols = `id, user_id, mapping_id, name, quantity, unit, category, storage_days, added_at,
|
||||
const selectCols = `id, user_id, primary_ingredient_id, name, quantity, unit, category, storage_days, added_at,
|
||||
(added_at + storage_days * INTERVAL '1 day') AS expires_at`
|
||||
|
||||
// List returns all products for a user, sorted by expires_at ASC.
|
||||
@@ -57,11 +57,17 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
||||
qty = 1
|
||||
}
|
||||
|
||||
// Accept both new and legacy field names.
|
||||
primaryID := req.PrimaryIngredientID
|
||||
if primaryID == nil {
|
||||
primaryID = req.MappingID
|
||||
}
|
||||
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO products (user_id, mapping_id, name, quantity, unit, category, storage_days)
|
||||
INSERT INTO products (user_id, primary_ingredient_id, name, quantity, unit, category, storage_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING `+selectCols,
|
||||
userID, req.MappingID, req.Name, qty, unit, req.Category, storageDays,
|
||||
userID, primaryID, req.Name, qty, unit, req.Category, storageDays,
|
||||
)
|
||||
return scanProduct(row)
|
||||
}
|
||||
@@ -144,11 +150,11 @@ func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string
|
||||
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
|
||||
switch {
|
||||
case daysLeft <= 0:
|
||||
line += " (истекает сегодня ⚠)"
|
||||
line += " (expires today ⚠)"
|
||||
case daysLeft == 1:
|
||||
line += " (истекает завтра ⚠)"
|
||||
line += " (expires tomorrow ⚠)"
|
||||
case daysLeft <= 3:
|
||||
line += fmt.Sprintf(" (истекает через %d дня ⚠)", daysLeft)
|
||||
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
@@ -160,7 +166,7 @@ func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string
|
||||
func scanProduct(row pgx.Row) (*Product, error) {
|
||||
var p Product
|
||||
err := row.Scan(
|
||||
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
|
||||
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
|
||||
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -175,7 +181,7 @@ func collectProducts(rows pgx.Rows) ([]*Product, error) {
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
|
||||
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
|
||||
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan product: %w", err)
|
||||
|
||||
58
backend/internal/recipe/handler.go
Normal file
58
backend/internal/recipe/handler.go
Normal 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)
|
||||
}
|
||||
@@ -1,28 +1,18 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
// Recipe is a recipe record in the database.
|
||||
// Title, Description, Ingredients, and Steps hold the content for the language
|
||||
// resolved at query time (English by default, or from recipe_translations when
|
||||
// a matching row exists for the requested language).
|
||||
// Recipe is a cooking variant of a Dish in the catalog.
|
||||
// It links to a Dish for all presentational data (name, image, cuisine, tags).
|
||||
type Recipe struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"` // spoonacular | ai | user
|
||||
SpoonacularID *int `json:"spoonacular_id"`
|
||||
ID string `json:"id"`
|
||||
DishID string `json:"dish_id"`
|
||||
Source string `json:"source"` // ai | user | spoonacular
|
||||
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
|
||||
Cuisine *string `json:"cuisine"`
|
||||
Difficulty *string `json:"difficulty"` // easy | medium | hard
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
|
||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||
@@ -30,29 +20,29 @@ type Recipe struct {
|
||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||
FiberPerServing *float64 `json:"fiber_per_serving"`
|
||||
|
||||
Ingredients json.RawMessage `json:"ingredients"` // []RecipeIngredient
|
||||
Steps json.RawMessage `json:"steps"` // []RecipeStep
|
||||
Tags json.RawMessage `json:"tags"` // []string
|
||||
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||
Steps []RecipeStep `json:"steps"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
|
||||
AvgRating float64 `json:"avg_rating"`
|
||||
ReviewCount int `json:"review_count"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RecipeIngredient is a single ingredient in a recipe's JSONB array.
|
||||
// RecipeIngredient is a single ingredient row from recipe_ingredients.
|
||||
type RecipeIngredient struct {
|
||||
MappingID *string `json:"mapping_id"`
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
Optional bool `json:"optional"`
|
||||
ID string `json:"id"`
|
||||
IngredientID *string `json:"ingredient_id"`
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
UnitCode *string `json:"unit_code"`
|
||||
IsOptional bool `json:"is_optional"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// RecipeStep is a single step in a recipe's JSONB array.
|
||||
// RecipeStep is a single step row from recipe_steps.
|
||||
type RecipeStep struct {
|
||||
Number int `json:"number"`
|
||||
ID string `json:"id"`
|
||||
StepNumber int `json:"step_number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
|
||||
@@ -2,7 +2,6 @@ package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for recipes and their translations.
|
||||
// Repository handles persistence for recipes and their relational sub-tables.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -21,77 +20,39 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a recipe (English canonical content only).
|
||||
// Conflict is resolved on spoonacular_id.
|
||||
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
|
||||
query := `
|
||||
INSERT INTO recipes (
|
||||
source, spoonacular_id,
|
||||
title, description,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
ON CONFLICT (spoonacular_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
cuisine = EXCLUDED.cuisine,
|
||||
difficulty = EXCLUDED.difficulty,
|
||||
prep_time_min = EXCLUDED.prep_time_min,
|
||||
cook_time_min = EXCLUDED.cook_time_min,
|
||||
servings = EXCLUDED.servings,
|
||||
image_url = EXCLUDED.image_url,
|
||||
calories_per_serving = EXCLUDED.calories_per_serving,
|
||||
protein_per_serving = EXCLUDED.protein_per_serving,
|
||||
fat_per_serving = EXCLUDED.fat_per_serving,
|
||||
carbs_per_serving = EXCLUDED.carbs_per_serving,
|
||||
fiber_per_serving = EXCLUDED.fiber_per_serving,
|
||||
ingredients = EXCLUDED.ingredients,
|
||||
steps = EXCLUDED.steps,
|
||||
tags = EXCLUDED.tags,
|
||||
updated_at = now()
|
||||
RETURNING id, source, spoonacular_id,
|
||||
title, description,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags,
|
||||
avg_rating, review_count, created_by, created_at, updated_at`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query,
|
||||
recipe.Source, recipe.SpoonacularID,
|
||||
recipe.Title, recipe.Description,
|
||||
recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
|
||||
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
|
||||
recipe.Ingredients, recipe.Steps, recipe.Tags,
|
||||
)
|
||||
return scanRecipe(row)
|
||||
}
|
||||
|
||||
// GetByID returns a recipe by UUID, with content resolved for the language
|
||||
// stored in ctx (falls back to English when no translation exists).
|
||||
// GetByID returns a recipe with its ingredients and steps.
|
||||
// Text is resolved for the language stored in ctx (English fallback).
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
query := `
|
||||
SELECT r.id, r.source, r.spoonacular_id,
|
||||
COALESCE(rt.title, r.title) AS title,
|
||||
COALESCE(rt.description, r.description) AS description,
|
||||
r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving,
|
||||
COALESCE(rt.ingredients, r.ingredients) AS ingredients,
|
||||
COALESCE(rt.steps, r.steps) AS steps,
|
||||
r.tags,
|
||||
r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at
|
||||
|
||||
const q = `
|
||||
SELECT r.id, r.dish_id, r.source, r.difficulty,
|
||||
r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving,
|
||||
r.carbs_per_serving, r.fiber_per_serving,
|
||||
rt.notes,
|
||||
r.created_at, r.updated_at
|
||||
FROM recipes r
|
||||
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
|
||||
WHERE r.id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query, id, lang)
|
||||
row := r.pool.QueryRow(ctx, q, id, lang)
|
||||
rec, err := scanRecipe(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return rec, err
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get recipe %s: %w", id, err)
|
||||
}
|
||||
|
||||
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Count returns the total number of recipes.
|
||||
@@ -103,97 +64,79 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListMissingTranslation returns Spoonacular recipes that have no translation
|
||||
// for the given language, ordered by review_count DESC.
|
||||
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) {
|
||||
query := `
|
||||
SELECT id, source, spoonacular_id,
|
||||
title, description,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags,
|
||||
avg_rating, review_count, created_by, created_at, updated_at
|
||||
FROM recipes
|
||||
WHERE source = 'spoonacular'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM recipe_translations rt
|
||||
WHERE rt.recipe_id = recipes.id AND rt.lang = $3
|
||||
)
|
||||
ORDER BY review_count DESC
|
||||
LIMIT $1 OFFSET $2`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, limit, offset, lang)
|
||||
// loadIngredients fills rec.Ingredients from recipe_ingredients.
|
||||
func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT ri.id, ri.ingredient_id,
|
||||
COALESCE(rit.name, ri.name) AS name,
|
||||
ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
|
||||
FROM recipe_ingredients ri
|
||||
LEFT JOIN recipe_ingredient_translations rit
|
||||
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||
WHERE ri.recipe_id = $1
|
||||
ORDER BY ri.sort_order`, rec.ID, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
|
||||
return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectRecipes(rows)
|
||||
}
|
||||
|
||||
// UpsertTranslation inserts or replaces a recipe translation for a specific language.
|
||||
func (r *Repository) UpsertTranslation(
|
||||
ctx context.Context,
|
||||
id, lang string,
|
||||
title, description *string,
|
||||
ingredients, steps json.RawMessage,
|
||||
) error {
|
||||
query := `
|
||||
INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (recipe_id, lang) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
ingredients = EXCLUDED.ingredients,
|
||||
steps = EXCLUDED.steps`
|
||||
|
||||
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
|
||||
return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err)
|
||||
for rows.Next() {
|
||||
var ing RecipeIngredient
|
||||
if err := rows.Scan(
|
||||
&ing.ID, &ing.IngredientID, &ing.Name,
|
||||
&ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan ingredient: %w", err)
|
||||
}
|
||||
rec.Ingredients = append(rec.Ingredients, ing)
|
||||
}
|
||||
return nil
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
// loadSteps fills rec.Steps from recipe_steps.
|
||||
func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT rs.id, rs.step_number,
|
||||
COALESCE(rst.description, rs.description) AS description,
|
||||
rs.timer_seconds, rs.image_url
|
||||
FROM recipe_steps rs
|
||||
LEFT JOIN recipe_step_translations rst
|
||||
ON rst.step_id = rs.id AND rst.lang = $2
|
||||
WHERE rs.recipe_id = $1
|
||||
ORDER BY rs.step_number`, rec.ID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var s RecipeStep
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan step: %w", err)
|
||||
}
|
||||
rec.Steps = append(rec.Steps, s)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
func scanRecipe(row pgx.Row) (*Recipe, error) {
|
||||
var rec Recipe
|
||||
var ingredients, steps, tags []byte
|
||||
|
||||
err := row.Scan(
|
||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
||||
&rec.Title, &rec.Description,
|
||||
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&ingredients, &steps, &tags,
|
||||
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
|
||||
&rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
|
||||
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
|
||||
&rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&rec.Notes,
|
||||
&rec.CreatedAt, &rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Ingredients = json.RawMessage(ingredients)
|
||||
rec.Steps = json.RawMessage(steps)
|
||||
rec.Tags = json.RawMessage(tags)
|
||||
rec.Ingredients = []RecipeIngredient{}
|
||||
rec.Steps = []RecipeStep{}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
|
||||
var result []*Recipe
|
||||
for rows.Next() {
|
||||
var rec Recipe
|
||||
var ingredients, steps, tags []byte
|
||||
if err := rows.Scan(
|
||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
||||
&rec.Title, &rec.Description,
|
||||
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&ingredients, &steps, &tags,
|
||||
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan recipe: %w", err)
|
||||
}
|
||||
rec.Ingredients = json.RawMessage(ingredients)
|
||||
rec.Steps = json.RawMessage(steps)
|
||||
rec.Tags = json.RawMessage(tags)
|
||||
result = append(result, &rec)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -4,132 +4,11 @@ package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/food-ai/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestRecipeRepository_Upsert_Insert(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 10001
|
||||
cuisine := "italian"
|
||||
diff := "easy"
|
||||
cookTime := 30
|
||||
servings := 4
|
||||
|
||||
rec := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Pasta Carbonara",
|
||||
Cuisine: &cuisine,
|
||||
Difficulty: &diff,
|
||||
CookTimeMin: &cookTime,
|
||||
Servings: &servings,
|
||||
Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`),
|
||||
Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`),
|
||||
Tags: json.RawMessage(`["italian"]`),
|
||||
}
|
||||
|
||||
got, err := repo.Upsert(ctx, rec)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if got.ID == "" {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
if got.Title != "Pasta Carbonara" {
|
||||
t.Errorf("title: want Pasta Carbonara, got %s", got.Title)
|
||||
}
|
||||
if got.SpoonacularID == nil || *got.SpoonacularID != id {
|
||||
t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 20001
|
||||
cuisine := "mexican"
|
||||
diff := "medium"
|
||||
|
||||
first := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Tacos",
|
||||
Cuisine: &cuisine,
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
}
|
||||
got1, err := repo.Upsert(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
|
||||
second := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Beef Tacos",
|
||||
Cuisine: &cuisine,
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
}
|
||||
got2, err := repo.Upsert(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
|
||||
if got1.ID != got2.ID {
|
||||
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
|
||||
}
|
||||
if got2.Title != "Beef Tacos" {
|
||||
t.Errorf("title not updated: got %s", got2.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_GetByID_Found(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 30001
|
||||
diff := "easy"
|
||||
rec := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Greek Salad",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`["vegetarian"]`),
|
||||
}
|
||||
saved, err := repo.Upsert(ctx, rec)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if got.Title != "Greek Salad" {
|
||||
t.Errorf("want Greek Salad, got %s", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
@@ -144,181 +23,13 @@ func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_ListMissingTranslation_Pagination(t *testing.T) {
|
||||
func TestRecipeRepository_Count(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
diff := "easy"
|
||||
for i := 0; i < 5; i++ {
|
||||
spID := 40000 + i
|
||||
_, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &spID,
|
||||
Title: "Recipe " + string(rune('A'+i)),
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert recipe %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0)
|
||||
_, err := repo.Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list missing translation: %v", err)
|
||||
}
|
||||
if len(missing) != 3 {
|
||||
t.Errorf("expected 3 results with limit=3, got %d", len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_UpsertTranslation(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 50001
|
||||
diff := "medium"
|
||||
saved, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Chicken Tikka Masala",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
titleRu := "Курица Тикка Масала"
|
||||
descRu := "Классическое индийское блюдо"
|
||||
stepsRu := json.RawMessage(`[{"number":1,"description":"Разогрейте масло"}]`)
|
||||
|
||||
if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); err != nil {
|
||||
t.Fatalf("upsert translation: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve with Russian context — title and steps should be translated.
|
||||
ruCtx := locale.WithLang(ctx, "ru")
|
||||
got, err := repo.GetByID(ruCtx, saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.Title != titleRu {
|
||||
t.Errorf("expected title=%q, got %q", titleRu, got.Title)
|
||||
}
|
||||
if got.Description == nil || *got.Description != descRu {
|
||||
t.Errorf("expected description=%q, got %v", descRu, got.Description)
|
||||
}
|
||||
|
||||
var steps []RecipeStep
|
||||
if err := json.Unmarshal(got.Steps, &steps); err != nil {
|
||||
t.Fatalf("unmarshal steps: %v", err)
|
||||
}
|
||||
if len(steps) == 0 || steps[0].Description != "Разогрейте масло" {
|
||||
t.Errorf("expected Russian step description, got %v", steps)
|
||||
}
|
||||
|
||||
// Retrieve with English context — should return original English content.
|
||||
enCtx := locale.WithLang(ctx, "en")
|
||||
gotEn, err := repo.GetByID(enCtx, saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id (en): %v", err)
|
||||
}
|
||||
if gotEn.Title != "Chicken Tikka Masala" {
|
||||
t.Errorf("expected English title, got %q", gotEn.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_ListMissingTranslation_ExcludesTranslated(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
diff := "easy"
|
||||
|
||||
// Insert untranslated recipes.
|
||||
for i := 0; i < 3; i++ {
|
||||
spID := 60000 + i
|
||||
_, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &spID,
|
||||
Title: "Untranslated " + string(rune('A'+i)),
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert one recipe and add a Russian translation.
|
||||
spID := 60100
|
||||
translated, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &spID,
|
||||
Title: "Translated Recipe",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert translated: %v", err)
|
||||
}
|
||||
titleRu := "Переведённый рецепт"
|
||||
if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil {
|
||||
t.Fatalf("upsert translation: %v", err)
|
||||
}
|
||||
|
||||
missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list missing translation: %v", err)
|
||||
}
|
||||
for _, r := range missing {
|
||||
if r.Title == "Translated Recipe" {
|
||||
t.Error("translated recipe should not appear in ListMissingTranslation")
|
||||
}
|
||||
}
|
||||
if len(missing) < 3 {
|
||||
t.Errorf("expected at least 3 missing, got %d", len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_GIN_Tags(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 70001
|
||||
diff := "easy"
|
||||
_, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Veggie Bowl",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`["vegetarian","gluten-free"]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
// GIN index query: tags @> '["vegetarian"]'
|
||||
var count int
|
||||
row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 vegetarian recipe, got %d", count)
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "title is required")
|
||||
if req.Title == "" && req.RecipeID == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "title or recipe_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,9 +64,8 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
|
||||
return
|
||||
}
|
||||
|
||||
if recipes == nil {
|
||||
recipes = []*SavedRecipe{}
|
||||
recipes = []*UserSavedRecipe{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, recipes)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,76 @@
|
||||
package savedrecipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
// SavedRecipe is a recipe saved by a specific user.
|
||||
type SavedRecipe struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
Ingredients json.RawMessage `json:"ingredients"`
|
||||
Steps json.RawMessage `json:"steps"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
Nutrition json.RawMessage `json:"nutrition_per_serving"`
|
||||
Source string `json:"source"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
// UserSavedRecipe is a user's bookmark referencing a catalog recipe.
|
||||
// Display fields are populated by joining dishes + recipes.
|
||||
type UserSavedRecipe struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
|
||||
// Display data — joined from dishes + recipes.
|
||||
DishName string `json:"title"` // dish name used as display title
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
CuisineSlug *string `json:"cuisine_slug"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
|
||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||
FatPerServing *float64 `json:"fat_per_serving"`
|
||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||
|
||||
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||
Steps []RecipeStep `json:"steps"`
|
||||
}
|
||||
|
||||
// RecipeIngredient is a single ingredient row.
|
||||
type RecipeIngredient struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
UnitCode *string `json:"unit_code"`
|
||||
IsOptional bool `json:"is_optional"`
|
||||
}
|
||||
|
||||
// RecipeStep is a single step row.
|
||||
type RecipeStep struct {
|
||||
StepNumber int `json:"number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
}
|
||||
|
||||
// SaveRequest is the body for POST /saved-recipes.
|
||||
// When recipe_id is provided, the existing catalog recipe is bookmarked.
|
||||
// Otherwise a new dish+recipe is created from the supplied fields.
|
||||
type SaveRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
PrepTimeMin int `json:"prep_time_min"`
|
||||
CookTimeMin int `json:"cook_time_min"`
|
||||
Servings int `json:"servings"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Ingredients json.RawMessage `json:"ingredients"`
|
||||
Steps json.RawMessage `json:"steps"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
Nutrition json.RawMessage `json:"nutrition_per_serving"`
|
||||
Source string `json:"source"`
|
||||
RecipeID string `json:"recipe_id"` // optional: bookmark existing recipe
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
PrepTimeMin int `json:"prep_time_min"`
|
||||
CookTimeMin int `json:"cook_time_min"`
|
||||
Servings int `json:"servings"`
|
||||
ImageURL string `json:"image_url"`
|
||||
// Ingredients / Steps / Tags / Nutrition are JSONB for backward compatibility
|
||||
// with the recommendation flow that sends the full Gemini response.
|
||||
Ingredients interface{} `json:"ingredients"`
|
||||
Steps interface{} `json:"steps"`
|
||||
Tags interface{} `json:"tags"`
|
||||
Nutrition interface{} `json:"nutrition_per_serving"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
||||
var ErrNotFound = errorString("saved recipe not found")
|
||||
|
||||
type errorString string
|
||||
|
||||
func (e errorString) Error() string { return string(e) }
|
||||
|
||||
@@ -6,132 +6,244 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
||||
var ErrNotFound = errors.New("saved recipe not found")
|
||||
|
||||
// Repository handles persistence for saved recipes and their translations.
|
||||
// Repository handles persistence for user_saved_recipes.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
dishRepo *dish.Repository
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository {
|
||||
return &Repository{pool: pool, dishRepo: dishRepo}
|
||||
}
|
||||
|
||||
// Save persists a recipe for userID and returns the stored record.
|
||||
// The canonical content (any language) is stored directly in saved_recipes.
|
||||
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
|
||||
const query = `
|
||||
INSERT INTO saved_recipes (
|
||||
user_id, title, description, cuisine, difficulty,
|
||||
prep_time_min, cook_time_min, servings, image_url,
|
||||
ingredients, steps, tags, nutrition, source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, title, description, cuisine, difficulty,
|
||||
prep_time_min, cook_time_min, servings, image_url,
|
||||
ingredients, steps, tags, nutrition, source, saved_at`
|
||||
// Save bookmarks a recipe for the user.
|
||||
// If req.RecipeID is set, that existing catalog recipe is bookmarked.
|
||||
// Otherwise a new dish + recipe is created from the supplied fields.
|
||||
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) {
|
||||
recipeID := req.RecipeID
|
||||
|
||||
description := nullableStr(req.Description)
|
||||
cuisine := nullableStr(req.Cuisine)
|
||||
difficulty := nullableStr(req.Difficulty)
|
||||
imageURL := nullableStr(req.ImageURL)
|
||||
prepTime := nullableInt(req.PrepTimeMin)
|
||||
cookTime := nullableInt(req.CookTimeMin)
|
||||
servings := nullableInt(req.Servings)
|
||||
if recipeID == "" {
|
||||
// Build a dish.CreateRequest from the save body.
|
||||
cr := dish.CreateRequest{
|
||||
Name: req.Title,
|
||||
Description: req.Description,
|
||||
CuisineSlug: mapCuisineSlug(req.Cuisine),
|
||||
ImageURL: req.ImageURL,
|
||||
Source: req.Source,
|
||||
Difficulty: req.Difficulty,
|
||||
PrepTimeMin: req.PrepTimeMin,
|
||||
CookTimeMin: req.CookTimeMin,
|
||||
Servings: req.Servings,
|
||||
}
|
||||
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "ai"
|
||||
// Unmarshal ingredients.
|
||||
if req.Ingredients != nil {
|
||||
switch v := req.Ingredients.(type) {
|
||||
case []interface{}:
|
||||
for i, item := range v {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ing := dish.IngredientInput{
|
||||
Name: strVal(m["name"]),
|
||||
Amount: floatVal(m["amount"]),
|
||||
Unit: strVal(m["unit"]),
|
||||
}
|
||||
cr.Ingredients = append(cr.Ingredients, ing)
|
||||
_ = i
|
||||
}
|
||||
case json.RawMessage:
|
||||
var items []dish.IngredientInput
|
||||
if err := json.Unmarshal(v, &items); err == nil {
|
||||
cr.Ingredients = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal steps.
|
||||
if req.Steps != nil {
|
||||
switch v := req.Steps.(type) {
|
||||
case []interface{}:
|
||||
for i, item := range v {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
num := int(floatVal(m["number"]))
|
||||
if num <= 0 {
|
||||
num = i + 1
|
||||
}
|
||||
step := dish.StepInput{
|
||||
Number: num,
|
||||
Description: strVal(m["description"]),
|
||||
}
|
||||
cr.Steps = append(cr.Steps, step)
|
||||
}
|
||||
case json.RawMessage:
|
||||
var items []dish.StepInput
|
||||
if err := json.Unmarshal(v, &items); err == nil {
|
||||
cr.Steps = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal tags.
|
||||
if req.Tags != nil {
|
||||
switch v := req.Tags.(type) {
|
||||
case []interface{}:
|
||||
for _, t := range v {
|
||||
if s, ok := t.(string); ok {
|
||||
cr.Tags = append(cr.Tags, s)
|
||||
}
|
||||
}
|
||||
case json.RawMessage:
|
||||
var items []string
|
||||
if err := json.Unmarshal(v, &items); err == nil {
|
||||
cr.Tags = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal nutrition.
|
||||
if req.Nutrition != nil {
|
||||
switch v := req.Nutrition.(type) {
|
||||
case map[string]interface{}:
|
||||
cr.Calories = floatVal(v["calories"])
|
||||
cr.Protein = floatVal(v["protein_g"])
|
||||
cr.Fat = floatVal(v["fat_g"])
|
||||
cr.Carbs = floatVal(v["carbs_g"])
|
||||
case json.RawMessage:
|
||||
var nut struct {
|
||||
Calories float64 `json:"calories"`
|
||||
Protein float64 `json:"protein_g"`
|
||||
Fat float64 `json:"fat_g"`
|
||||
Carbs float64 `json:"carbs_g"`
|
||||
}
|
||||
if err := json.Unmarshal(v, &nut); err == nil {
|
||||
cr.Calories = nut.Calories
|
||||
cr.Protein = nut.Protein
|
||||
cr.Fat = nut.Fat
|
||||
cr.Carbs = nut.Carbs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
recipeID, err = r.dishRepo.Create(ctx, cr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create dish+recipe: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ingredients := defaultJSONArray(req.Ingredients)
|
||||
steps := defaultJSONArray(req.Steps)
|
||||
tags := defaultJSONArray(req.Tags)
|
||||
// Insert bookmark.
|
||||
const q = `
|
||||
INSERT INTO user_saved_recipes (user_id, recipe_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, recipe_id) DO UPDATE SET saved_at = now()
|
||||
RETURNING id, user_id, recipe_id, saved_at`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query,
|
||||
userID, req.Title, description, cuisine, difficulty,
|
||||
prepTime, cookTime, servings, imageURL,
|
||||
ingredients, steps, tags, req.Nutrition, source,
|
||||
var usr UserSavedRecipe
|
||||
err := r.pool.QueryRow(ctx, q, userID, recipeID).Scan(
|
||||
&usr.ID, &usr.UserID, &usr.RecipeID, &usr.SavedAt,
|
||||
)
|
||||
return scanRow(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert bookmark: %w", err)
|
||||
}
|
||||
|
||||
return r.enrichOne(ctx, &usr)
|
||||
}
|
||||
|
||||
// List returns all saved recipes for userID ordered by saved_at DESC.
|
||||
// Text content (title, description, ingredients, steps) is resolved for the
|
||||
// language stored in ctx, falling back to the canonical content when no
|
||||
// translation exists.
|
||||
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
|
||||
// List returns all bookmarked recipes for userID ordered by saved_at DESC.
|
||||
func (r *Repository) List(ctx context.Context, userID string) ([]*UserSavedRecipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
const query = `
|
||||
SELECT sr.id, sr.user_id,
|
||||
COALESCE(srt.title, sr.title) AS title,
|
||||
COALESCE(srt.description, sr.description) AS description,
|
||||
sr.cuisine, sr.difficulty,
|
||||
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
|
||||
COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
|
||||
COALESCE(srt.steps, sr.steps) AS steps,
|
||||
sr.tags, sr.nutrition, sr.source, sr.saved_at
|
||||
FROM saved_recipes sr
|
||||
LEFT JOIN saved_recipe_translations srt
|
||||
ON srt.saved_recipe_id = sr.id AND srt.lang = $2
|
||||
WHERE sr.user_id = $1
|
||||
ORDER BY sr.saved_at DESC`
|
||||
const q = `
|
||||
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
|
||||
COALESCE(dt.name, d.name) AS dish_name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.cuisine_slug,
|
||||
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
|
||||
FROM user_saved_recipes usr
|
||||
JOIN recipes r ON r.id = usr.recipe_id
|
||||
JOIN dishes d ON d.id = r.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||
WHERE usr.user_id = $1
|
||||
ORDER BY usr.saved_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID, lang)
|
||||
rows, err := r.pool.Query(ctx, q, userID, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list saved recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*SavedRecipe
|
||||
var result []*UserSavedRecipe
|
||||
for rows.Next() {
|
||||
rec, err := scanRow(rows)
|
||||
rec, err := scanUSR(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan saved recipe: %w", err)
|
||||
}
|
||||
if err := r.loadTags(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, rec)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns the saved recipe with id for userID, or nil if not found.
|
||||
// Text content is resolved for the language stored in ctx.
|
||||
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
|
||||
// GetByID returns a bookmarked recipe by its bookmark ID for userID.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
const query = `
|
||||
SELECT sr.id, sr.user_id,
|
||||
COALESCE(srt.title, sr.title) AS title,
|
||||
COALESCE(srt.description, sr.description) AS description,
|
||||
sr.cuisine, sr.difficulty,
|
||||
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
|
||||
COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
|
||||
COALESCE(srt.steps, sr.steps) AS steps,
|
||||
sr.tags, sr.nutrition, sr.source, sr.saved_at
|
||||
FROM saved_recipes sr
|
||||
LEFT JOIN saved_recipe_translations srt
|
||||
ON srt.saved_recipe_id = sr.id AND srt.lang = $3
|
||||
WHERE sr.id = $1 AND sr.user_id = $2`
|
||||
const q = `
|
||||
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
|
||||
COALESCE(dt.name, d.name) AS dish_name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.cuisine_slug,
|
||||
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
|
||||
FROM user_saved_recipes usr
|
||||
JOIN recipes r ON r.id = usr.recipe_id
|
||||
JOIN dishes d ON d.id = r.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||
WHERE usr.id = $1 AND usr.user_id = $2`
|
||||
|
||||
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID, lang))
|
||||
rec, err := scanUSR(r.pool.QueryRow(ctx, q, id, userID, lang))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return rec, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadTags(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Delete removes the saved recipe with id for userID.
|
||||
// Returns ErrNotFound if the record does not exist.
|
||||
// Delete removes a bookmark.
|
||||
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`,
|
||||
id, userID,
|
||||
)
|
||||
`DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete saved recipe: %w", err)
|
||||
}
|
||||
@@ -141,73 +253,170 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertTranslation inserts or replaces a translation for a saved recipe.
|
||||
func (r *Repository) UpsertTranslation(
|
||||
ctx context.Context,
|
||||
id, lang string,
|
||||
title, description *string,
|
||||
ingredients, steps json.RawMessage,
|
||||
) error {
|
||||
const query = `
|
||||
INSERT INTO saved_recipe_translations (saved_recipe_id, lang, title, description, ingredients, steps)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (saved_recipe_id, lang) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
ingredients = EXCLUDED.ingredients,
|
||||
steps = EXCLUDED.steps,
|
||||
generated_at = now()`
|
||||
|
||||
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
|
||||
return fmt.Errorf("upsert saved recipe translation %s/%s: %w", id, lang, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type scannable interface {
|
||||
func (r *Repository) enrichOne(ctx context.Context, usr *UserSavedRecipe) (*UserSavedRecipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
const q = `
|
||||
SELECT COALESCE(dt.name, d.name) AS dish_name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.cuisine_slug,
|
||||
rec.difficulty, rec.prep_time_min, rec.cook_time_min, rec.servings,
|
||||
rec.calories_per_serving, rec.protein_per_serving,
|
||||
rec.fat_per_serving, rec.carbs_per_serving
|
||||
FROM recipes rec
|
||||
JOIN dishes d ON d.id = rec.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||
WHERE rec.id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, usr.RecipeID, lang)
|
||||
if err := row.Scan(
|
||||
&usr.DishName, &usr.Description, &usr.ImageURL, &usr.CuisineSlug,
|
||||
&usr.Difficulty, &usr.PrepTimeMin, &usr.CookTimeMin, &usr.Servings,
|
||||
&usr.CaloriesPerServing, &usr.ProteinPerServing, &usr.FatPerServing, &usr.CarbsPerServing,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("enrich saved recipe: %w", err)
|
||||
}
|
||||
if err := r.loadTags(ctx, usr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadIngredients(ctx, usr, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usr, r.loadSteps(ctx, usr, lang)
|
||||
}
|
||||
|
||||
func (r *Repository) loadTags(ctx context.Context, usr *UserSavedRecipe) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT dt.tag_slug
|
||||
FROM dish_tags dt
|
||||
JOIN recipes rec ON rec.dish_id = dt.dish_id
|
||||
JOIN user_saved_recipes usr ON usr.recipe_id = rec.id
|
||||
WHERE usr.id = $1
|
||||
ORDER BY dt.tag_slug`, usr.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load tags for saved recipe %s: %w", usr.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
usr.Tags = []string{}
|
||||
for rows.Next() {
|
||||
var slug string
|
||||
if err := rows.Scan(&slug); err != nil {
|
||||
return err
|
||||
}
|
||||
usr.Tags = append(usr.Tags, slug)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) loadIngredients(ctx context.Context, usr *UserSavedRecipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT COALESCE(rit.name, ri.name) AS name,
|
||||
ri.amount, ri.unit_code, ri.is_optional
|
||||
FROM recipe_ingredients ri
|
||||
LEFT JOIN recipe_ingredient_translations rit
|
||||
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||
WHERE ri.recipe_id = $1
|
||||
ORDER BY ri.sort_order`, usr.RecipeID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load ingredients for saved recipe %s: %w", usr.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
usr.Ingredients = []RecipeIngredient{}
|
||||
for rows.Next() {
|
||||
var ing RecipeIngredient
|
||||
if err := rows.Scan(&ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional); err != nil {
|
||||
return err
|
||||
}
|
||||
usr.Ingredients = append(usr.Ingredients, ing)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) loadSteps(ctx context.Context, usr *UserSavedRecipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT rs.step_number,
|
||||
COALESCE(rst.description, rs.description) AS description,
|
||||
rs.timer_seconds
|
||||
FROM recipe_steps rs
|
||||
LEFT JOIN recipe_step_translations rst
|
||||
ON rst.step_id = rs.id AND rst.lang = $2
|
||||
WHERE rs.recipe_id = $1
|
||||
ORDER BY rs.step_number`, usr.RecipeID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load steps for saved recipe %s: %w", usr.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
usr.Steps = []RecipeStep{}
|
||||
for rows.Next() {
|
||||
var s RecipeStep
|
||||
if err := rows.Scan(&s.StepNumber, &s.Description, &s.TimerSeconds); err != nil {
|
||||
return err
|
||||
}
|
||||
usr.Steps = append(usr.Steps, s)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanRow(s scannable) (*SavedRecipe, error) {
|
||||
var rec SavedRecipe
|
||||
var ingredients, steps, tags, nutrition []byte
|
||||
func scanUSR(s rowScanner) (*UserSavedRecipe, error) {
|
||||
var r UserSavedRecipe
|
||||
err := s.Scan(
|
||||
&rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty,
|
||||
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||
&ingredients, &steps, &tags, &nutrition,
|
||||
&rec.Source, &rec.SavedAt,
|
||||
&r.ID, &r.UserID, &r.RecipeID, &r.SavedAt,
|
||||
&r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug,
|
||||
&r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings,
|
||||
&r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Ingredients = json.RawMessage(ingredients)
|
||||
rec.Steps = json.RawMessage(steps)
|
||||
rec.Tags = json.RawMessage(tags)
|
||||
if len(nutrition) > 0 {
|
||||
rec.Nutrition = json.RawMessage(nutrition)
|
||||
}
|
||||
return &rec, nil
|
||||
return &r, err
|
||||
}
|
||||
|
||||
func nullableStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||
// Falls back to "other".
|
||||
func mapCuisineSlug(cuisine string) string {
|
||||
known := map[string]string{
|
||||
"russian": "russian",
|
||||
"italian": "italian",
|
||||
"french": "french",
|
||||
"chinese": "chinese",
|
||||
"japanese": "japanese",
|
||||
"korean": "korean",
|
||||
"mexican": "mexican",
|
||||
"mediterranean": "mediterranean",
|
||||
"indian": "indian",
|
||||
"thai": "thai",
|
||||
"american": "american",
|
||||
"georgian": "georgian",
|
||||
"spanish": "spanish",
|
||||
"german": "german",
|
||||
"middle_eastern":"middle_eastern",
|
||||
"turkish": "turkish",
|
||||
"greek": "greek",
|
||||
"vietnamese": "vietnamese",
|
||||
"asian": "other",
|
||||
"european": "other",
|
||||
}
|
||||
return &s
|
||||
if s, ok := known[cuisine]; ok {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func nullableInt(n int) *int {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
func strVal(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return &n
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultJSONArray(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return json.RawMessage(`[]`)
|
||||
func floatVal(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
}
|
||||
return raw
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/cuisine"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/language"
|
||||
"github.com/food-ai/backend/internal/menu"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/tag"
|
||||
"github.com/food-ai/backend/internal/units"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
@@ -33,6 +37,8 @@ func NewRouter(
|
||||
menuHandler *menu.Handler,
|
||||
diaryHandler *diary.Handler,
|
||||
homeHandler *home.Handler,
|
||||
dishHandler *dish.Handler,
|
||||
recipeHandler *recipe.Handler,
|
||||
authMiddleware func(http.Handler) http.Handler,
|
||||
allowedOrigins []string,
|
||||
) *chi.Mux {
|
||||
@@ -49,6 +55,8 @@ func NewRouter(
|
||||
r.Get("/health", healthCheck(pool))
|
||||
r.Get("/languages", language.List)
|
||||
r.Get("/units", units.List)
|
||||
r.Get("/cuisines", cuisine.List)
|
||||
r.Get("/tags", tag.List)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", authHandler.Login)
|
||||
r.Post("/refresh", authHandler.Refresh)
|
||||
@@ -81,6 +89,13 @@ func NewRouter(
|
||||
r.Delete("/{id}", productHandler.Delete)
|
||||
})
|
||||
|
||||
r.Route("/dishes", func(r chi.Router) {
|
||||
r.Get("/", dishHandler.List)
|
||||
r.Get("/{id}", dishHandler.GetByID)
|
||||
})
|
||||
|
||||
r.Get("/recipes/{id}", recipeHandler.GetByID)
|
||||
|
||||
r.Route("/menu", func(r chi.Router) {
|
||||
r.Get("/", menuHandler.GetMenu)
|
||||
r.Put("/items/{id}", menuHandler.UpdateMenuItem)
|
||||
|
||||
28
backend/internal/tag/handler.go
Normal file
28
backend/internal/tag/handler.go
Normal 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})
|
||||
}
|
||||
79
backend/internal/tag/registry.go
Normal file
79
backend/internal/tag/registry.go
Normal 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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Generate UUID v7 (time-ordered, millisecond precision).
|
||||
-- Structure: 48-bit unix_ts_ms | 4-bit version (0111) | 12-bit rand_a | 2-bit variant (10) | 62-bit rand_b
|
||||
CREATE OR REPLACE FUNCTION uuid_generate_v7()
|
||||
RETURNS uuid
|
||||
AS $$
|
||||
DECLARE
|
||||
unix_ts_ms bytea;
|
||||
uuid_bytes bytea;
|
||||
BEGIN
|
||||
-- 48-bit Unix timestamp in milliseconds.
|
||||
-- int8send produces 8 big-endian bytes; skip the first 2 zero bytes to get 6.
|
||||
unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);
|
||||
|
||||
-- Use a random v4 UUID as the source of random bits for rand_a and rand_b.
|
||||
uuid_bytes = uuid_send(gen_random_uuid());
|
||||
|
||||
-- Overwrite bytes 0–5 with the timestamp (positions 1–6 in 1-indexed bytea).
|
||||
uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);
|
||||
|
||||
-- Set version nibble (bits 48–51) to 0111 (7).
|
||||
uuid_bytes = set_bit(uuid_bytes, 48, 0);
|
||||
uuid_bytes = set_bit(uuid_bytes, 49, 1);
|
||||
uuid_bytes = set_bit(uuid_bytes, 50, 1);
|
||||
uuid_bytes = set_bit(uuid_bytes, 51, 1);
|
||||
|
||||
-- Variant bits (64–65) stay at 10 as inherited from gen_random_uuid().
|
||||
|
||||
RETURN encode(uuid_bytes, 'hex')::uuid;
|
||||
END
|
||||
$$ LANGUAGE plpgsql VOLATILE;
|
||||
|
||||
CREATE TYPE user_plan AS ENUM ('free', 'paid');
|
||||
CREATE TYPE user_gender AS ENUM ('male', 'female');
|
||||
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
|
||||
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
avatar_url TEXT,
|
||||
|
||||
-- Body parameters
|
||||
height_cm SMALLINT,
|
||||
weight_kg DECIMAL(5,2),
|
||||
age SMALLINT,
|
||||
gender user_gender,
|
||||
activity activity_level,
|
||||
|
||||
-- Goal and calculated daily norm
|
||||
goal user_goal,
|
||||
daily_calories INTEGER,
|
||||
|
||||
-- Plan
|
||||
plan user_plan NOT NULL DEFAULT 'free',
|
||||
|
||||
-- Preferences (JSONB for flexibility)
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Refresh token
|
||||
refresh_token TEXT,
|
||||
token_expires_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_firebase_uid ON users (firebase_uid);
|
||||
CREATE INDEX idx_users_email ON users (email);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TYPE IF EXISTS activity_level;
|
||||
DROP TYPE IF EXISTS user_goal;
|
||||
DROP TYPE IF EXISTS user_gender;
|
||||
DROP TYPE IF EXISTS user_plan;
|
||||
DROP FUNCTION IF EXISTS uuid_generate_v7();
|
||||
452
backend/migrations/001_initial_schema.sql
Normal file
452
backend/migrations/001_initial_schema.sql
Normal 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;
|
||||
@@ -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;
|
||||
190
backend/migrations/002_seed_data.sql
Normal file
190
backend/migrations/002_seed_data.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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, '')));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user