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:
46
CLAUDE.md
46
CLAUDE.md
@@ -4,3 +4,49 @@
|
|||||||
|
|
||||||
- All code comments must be written in **English**.
|
- All code comments must be written in **English**.
|
||||||
- All git commit messages must be written in **English**.
|
- All git commit messages must be written in **English**.
|
||||||
|
|
||||||
|
## Localisation
|
||||||
|
|
||||||
|
**Rule:** Every table that stores human-readable text must have a companion `{table}_translations`
|
||||||
|
table. The base table always contains the English canonical text (used as fallback).
|
||||||
|
Translations live only in `_translations` tables — never as extra columns (`name_ru`, `title_de`).
|
||||||
|
|
||||||
|
### SQL pattern
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Base table — English canonical text always present
|
||||||
|
CREATE TABLE cuisines (
|
||||||
|
slug VARCHAR(50) PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
-- Translation companion
|
||||||
|
CREATE TABLE cuisine_translations (
|
||||||
|
cuisine_slug VARCHAR(50) REFERENCES cuisines(slug) ON DELETE CASCADE,
|
||||||
|
lang VARCHAR(10) NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (cuisine_slug, lang)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query pattern
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COALESCE(ct.name, c.name) AS name
|
||||||
|
FROM cuisines c
|
||||||
|
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $lang
|
||||||
|
```
|
||||||
|
|
||||||
|
### Already compliant tables
|
||||||
|
|
||||||
|
`units` + `unit_translations`, `ingredient_categories` + `ingredient_category_translations`,
|
||||||
|
`ingredients` + `ingredient_translations` + `ingredient_aliases`,
|
||||||
|
`recipes` + `recipe_translations`, `cuisines` + `cuisine_translations`,
|
||||||
|
`tags` + `tag_translations`, `dish_categories` + `dish_category_translations`,
|
||||||
|
`dishes` + `dish_translations`, `recipe_ingredients` + `recipe_ingredient_translations`,
|
||||||
|
`recipe_steps` + `recipe_step_translations`.
|
||||||
|
|
||||||
|
### Rule when adding new entities
|
||||||
|
|
||||||
|
When adding a new entity with text fields, always create a `{table}_translations`
|
||||||
|
companion table and use LEFT JOIN COALESCE in all read queries.
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
|
|
||||||
"github.com/food-ai/backend/internal/auth"
|
"github.com/food-ai/backend/internal/auth"
|
||||||
"github.com/food-ai/backend/internal/config"
|
"github.com/food-ai/backend/internal/config"
|
||||||
|
"github.com/food-ai/backend/internal/cuisine"
|
||||||
"github.com/food-ai/backend/internal/database"
|
"github.com/food-ai/backend/internal/database"
|
||||||
"github.com/food-ai/backend/internal/diary"
|
"github.com/food-ai/backend/internal/diary"
|
||||||
|
"github.com/food-ai/backend/internal/dish"
|
||||||
"github.com/food-ai/backend/internal/gemini"
|
"github.com/food-ai/backend/internal/gemini"
|
||||||
"github.com/food-ai/backend/internal/home"
|
"github.com/food-ai/backend/internal/home"
|
||||||
"github.com/food-ai/backend/internal/ingredient"
|
"github.com/food-ai/backend/internal/ingredient"
|
||||||
@@ -23,10 +25,12 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/units"
|
"github.com/food-ai/backend/internal/units"
|
||||||
"github.com/food-ai/backend/internal/pexels"
|
"github.com/food-ai/backend/internal/pexels"
|
||||||
"github.com/food-ai/backend/internal/product"
|
"github.com/food-ai/backend/internal/product"
|
||||||
|
"github.com/food-ai/backend/internal/recipe"
|
||||||
"github.com/food-ai/backend/internal/recognition"
|
"github.com/food-ai/backend/internal/recognition"
|
||||||
"github.com/food-ai/backend/internal/recommendation"
|
"github.com/food-ai/backend/internal/recommendation"
|
||||||
"github.com/food-ai/backend/internal/savedrecipe"
|
"github.com/food-ai/backend/internal/savedrecipe"
|
||||||
"github.com/food-ai/backend/internal/server"
|
"github.com/food-ai/backend/internal/server"
|
||||||
|
"github.com/food-ai/backend/internal/tag"
|
||||||
"github.com/food-ai/backend/internal/user"
|
"github.com/food-ai/backend/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +88,16 @@ func run() error {
|
|||||||
}
|
}
|
||||||
slog.Info("units loaded", "count", len(units.Records))
|
slog.Info("units loaded", "count", len(units.Records))
|
||||||
|
|
||||||
|
if err := cuisine.LoadFromDB(ctx, pool); err != nil {
|
||||||
|
return fmt.Errorf("load cuisines: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("cuisines loaded", "count", len(cuisine.Records))
|
||||||
|
|
||||||
|
if err := tag.LoadFromDB(ctx, pool); err != nil {
|
||||||
|
return fmt.Errorf("load tags: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("tags loaded", "count", len(tag.Records))
|
||||||
|
|
||||||
// Firebase auth
|
// Firebase auth
|
||||||
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,13 +137,21 @@ func run() error {
|
|||||||
// Recommendation domain
|
// Recommendation domain
|
||||||
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
|
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
|
||||||
|
|
||||||
|
// Dish domain
|
||||||
|
dishRepo := dish.NewRepository(pool)
|
||||||
|
dishHandler := dish.NewHandler(dishRepo)
|
||||||
|
|
||||||
|
// Recipe domain
|
||||||
|
recipeRepo := recipe.NewRepository(pool)
|
||||||
|
recipeHandler := recipe.NewHandler(recipeRepo)
|
||||||
|
|
||||||
// Saved recipes domain
|
// Saved recipes domain
|
||||||
savedRecipeRepo := savedrecipe.NewRepository(pool)
|
savedRecipeRepo := savedrecipe.NewRepository(pool, dishRepo)
|
||||||
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
|
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
|
||||||
|
|
||||||
// Menu domain
|
// Menu domain
|
||||||
menuRepo := menu.NewRepository(pool)
|
menuRepo := menu.NewRepository(pool)
|
||||||
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, savedRecipeRepo)
|
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, dishRepo)
|
||||||
|
|
||||||
// Diary domain
|
// Diary domain
|
||||||
diaryRepo := diary.NewRepository(pool)
|
diaryRepo := diary.NewRepository(pool)
|
||||||
@@ -151,6 +173,8 @@ func run() error {
|
|||||||
menuHandler,
|
menuHandler,
|
||||||
diaryHandler,
|
diaryHandler,
|
||||||
homeHandler,
|
homeHandler,
|
||||||
|
dishHandler,
|
||||||
|
recipeHandler,
|
||||||
authMW,
|
authMW,
|
||||||
cfg.AllowedOrigins,
|
cfg.AllowedOrigins,
|
||||||
)
|
)
|
||||||
|
|||||||
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,7 +14,9 @@ type Entry struct {
|
|||||||
FatG *float64 `json:"fat_g,omitempty"`
|
FatG *float64 `json:"fat_g,omitempty"`
|
||||||
CarbsG *float64 `json:"carbs_g,omitempty"`
|
CarbsG *float64 `json:"carbs_g,omitempty"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
DishID *string `json:"dish_id,omitempty"`
|
||||||
RecipeID *string `json:"recipe_id,omitempty"`
|
RecipeID *string `json:"recipe_id,omitempty"`
|
||||||
|
PortionG *float64 `json:"portion_g,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,5 +31,7 @@ type CreateRequest struct {
|
|||||||
FatG *float64 `json:"fat_g"`
|
FatG *float64 `json:"fat_g"`
|
||||||
CarbsG *float64 `json:"carbs_g"`
|
CarbsG *float64 `json:"carbs_g"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
DishID *string `json:"dish_id"`
|
||||||
RecipeID *string `json:"recipe_id"`
|
RecipeID *string `json:"recipe_id"`
|
||||||
|
PortionG *float64 `json:"portion_g"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
|
|||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, date::text, meal_type, name, portions,
|
SELECT id, date::text, meal_type, name, portions,
|
||||||
calories, protein_g, fat_g, carbs_g,
|
calories, protein_g, fat_g, carbs_g,
|
||||||
source, recipe_id, created_at
|
source, dish_id, recipe_id, portion_g, created_at
|
||||||
FROM meal_diary
|
FROM meal_diary
|
||||||
WHERE user_id = $1 AND date = $2::date
|
WHERE user_id = $1 AND date = $2::date
|
||||||
ORDER BY created_at ASC`, userID, date)
|
ORDER BY created_at ASC`, userID, date)
|
||||||
@@ -60,13 +60,13 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
|||||||
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
|
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
|
||||||
calories, protein_g, fat_g, carbs_g, source, recipe_id)
|
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g)
|
||||||
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING id, date::text, meal_type, name, portions,
|
RETURNING id, date::text, meal_type, name, portions,
|
||||||
calories, protein_g, fat_g, carbs_g, source, recipe_id, created_at`,
|
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`,
|
||||||
userID, req.Date, req.MealType, req.Name, portions,
|
userID, req.Date, req.MealType, req.Name, portions,
|
||||||
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
|
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
|
||||||
source, req.RecipeID,
|
source, req.DishID, req.RecipeID, req.PortionG,
|
||||||
)
|
)
|
||||||
return scanEntry(row)
|
return scanEntry(row)
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func scanEntry(s scannable) (*Entry, error) {
|
|||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
|
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
|
||||||
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
|
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
|
||||||
&e.Source, &e.RecipeID, &e.CreatedAt,
|
&e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt,
|
||||||
)
|
)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
|
|||||||
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"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository handles persistence for ingredient_mappings and their translations.
|
// Repository handles persistence for ingredients and their translations.
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,11 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
return &Repository{pool: pool}
|
return &Repository{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert inserts or updates an ingredient mapping (English canonical content).
|
// Upsert inserts or updates an ingredient (English canonical content).
|
||||||
// Conflict is resolved on canonical_name.
|
// Conflict is resolved on canonical_name.
|
||||||
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO ingredient_mappings (
|
INSERT INTO ingredients (
|
||||||
canonical_name,
|
canonical_name,
|
||||||
category, default_unit,
|
category, default_unit,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||||
@@ -54,7 +54,7 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
|
|||||||
return scanMappingWrite(row)
|
return scanMappingWrite(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID returns an ingredient mapping by UUID.
|
// GetByID returns an ingredient by UUID.
|
||||||
// CanonicalName and aliases are resolved for the language stored in ctx.
|
// CanonicalName and aliases are resolved for the language stored in ctx.
|
||||||
// Returns nil, nil if not found.
|
// Returns nil, nil if not found.
|
||||||
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
||||||
@@ -68,7 +68,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
|
|||||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
im.storage_days, im.created_at, im.updated_at,
|
im.storage_days, im.created_at, im.updated_at,
|
||||||
COALESCE(al.aliases, '[]'::json) AS aliases
|
COALESCE(al.aliases, '[]'::json) AS aliases
|
||||||
FROM ingredient_mappings im
|
FROM ingredients im
|
||||||
LEFT JOIN ingredient_translations it
|
LEFT JOIN ingredient_translations it
|
||||||
ON it.ingredient_id = im.id AND it.lang = $2
|
ON it.ingredient_id = im.id AND it.lang = $2
|
||||||
LEFT JOIN ingredient_category_translations ict
|
LEFT JOIN ingredient_category_translations ict
|
||||||
@@ -88,7 +88,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
|
|||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FuzzyMatch finds the single best matching ingredient mapping for a given name.
|
// FuzzyMatch finds the single best matching ingredient for a given name.
|
||||||
// Searches both English and translated names for the language in ctx.
|
// Searches both English and translated names for the language in ctx.
|
||||||
// Returns nil, nil when no match is found.
|
// Returns nil, nil when no match is found.
|
||||||
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
||||||
@@ -102,7 +102,7 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa
|
|||||||
return results[0], nil
|
return results[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search finds ingredient mappings matching the query string.
|
// Search finds ingredients matching the query string.
|
||||||
// Searches aliases table and translated names for the language in ctx.
|
// Searches aliases table and translated names for the language in ctx.
|
||||||
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@@ -118,7 +118,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In
|
|||||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
im.storage_days, im.created_at, im.updated_at,
|
im.storage_days, im.created_at, im.updated_at,
|
||||||
COALESCE(al.aliases, '[]'::json) AS aliases
|
COALESCE(al.aliases, '[]'::json) AS aliases
|
||||||
FROM ingredient_mappings im
|
FROM ingredients im
|
||||||
LEFT JOIN ingredient_translations it
|
LEFT JOIN ingredient_translations it
|
||||||
ON it.ingredient_id = im.id AND it.lang = $3
|
ON it.ingredient_id = im.id AND it.lang = $3
|
||||||
LEFT JOIN ingredient_category_translations ict
|
LEFT JOIN ingredient_category_translations ict
|
||||||
@@ -142,17 +142,17 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In
|
|||||||
|
|
||||||
rows, err := r.pool.Query(ctx, q, query, limit, lang)
|
rows, err := r.pool.Query(ctx, q, query, limit, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search ingredient_mappings: %w", err)
|
return nil, fmt.Errorf("search ingredients: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
return collectMappingsRead(rows)
|
return collectMappingsRead(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the total number of ingredient mappings.
|
// Count returns the total number of ingredients.
|
||||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil {
|
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil {
|
||||||
return 0, fmt.Errorf("count ingredient_mappings: %w", err)
|
return 0, fmt.Errorf("count ingredients: %w", err)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, li
|
|||||||
im.category, im.default_unit,
|
im.category, im.default_unit,
|
||||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
im.storage_days, im.created_at, im.updated_at
|
im.storage_days, im.created_at, im.updated_at
|
||||||
FROM ingredient_mappings im
|
FROM ingredients im
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM ingredient_translations it
|
SELECT 1 FROM ingredient_translations it
|
||||||
WHERE it.ingredient_id = im.id AND it.lang = $3
|
WHERE it.ingredient_id = im.id AND it.lang = $3
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/dish"
|
||||||
"github.com/food-ai/backend/internal/gemini"
|
"github.com/food-ai/backend/internal/gemini"
|
||||||
"github.com/food-ai/backend/internal/locale"
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
"github.com/food-ai/backend/internal/savedrecipe"
|
|
||||||
"github.com/food-ai/backend/internal/user"
|
"github.com/food-ai/backend/internal/user"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -33,9 +33,9 @@ type ProductLister interface {
|
|||||||
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipeSaver persists a single recipe and returns the stored record.
|
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
|
||||||
type RecipeSaver interface {
|
type RecipeSaver interface {
|
||||||
Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error)
|
Create(ctx context.Context, req dish.CreateRequest) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler handles menu and shopping-list endpoints.
|
// Handler handles menu and shopping-list endpoints.
|
||||||
@@ -136,7 +136,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
menuReq.AvailableProducts = products
|
menuReq.AvailableProducts = products
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 7-day plan via OpenAI.
|
// Generate 7-day plan via Gemini.
|
||||||
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
|
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("generate menu", "user_id", userID, "err", err)
|
slog.Error("generate menu", "user_id", userID, "err", err)
|
||||||
@@ -175,7 +175,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
|
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save all 21 recipes to saved_recipes.
|
// Persist all 21 recipes as dish+recipe rows.
|
||||||
type savedRef struct {
|
type savedRef struct {
|
||||||
day int
|
day int
|
||||||
meal int
|
meal int
|
||||||
@@ -184,13 +184,13 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
refs := make([]savedRef, 0, len(days)*3)
|
refs := make([]savedRef, 0, len(days)*3)
|
||||||
for di, day := range days {
|
for di, day := range days {
|
||||||
for mi, meal := range day.Meals {
|
for mi, meal := range day.Meals {
|
||||||
saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe))
|
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to save recipes")
|
writeError(w, http.StatusInternalServerError, "failed to save recipes")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refs = append(refs, savedRef{di, mi, saved.ID})
|
refs = append(refs, savedRef{di, mi, recipeID})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,33 +420,21 @@ func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]Shopp
|
|||||||
|
|
||||||
type key struct{ name, unit string }
|
type key struct{ name, unit string }
|
||||||
totals := map[key]float64{}
|
totals := map[key]float64{}
|
||||||
categories := map[string]string{} // name → category (from meal_type heuristic)
|
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var ingredients []struct {
|
unit := ""
|
||||||
Name string `json:"name"`
|
if row.UnitCode != nil {
|
||||||
Amount float64 `json:"amount"`
|
unit = *row.UnitCode
|
||||||
Unit string `json:"unit"`
|
|
||||||
}
|
|
||||||
if len(row.IngredientsJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, ing := range ingredients {
|
|
||||||
k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit}
|
|
||||||
totals[k] += ing.Amount
|
|
||||||
if _, ok := categories[k.name]; !ok {
|
|
||||||
categories[k.name] = "other"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit}
|
||||||
|
totals[k] += row.Amount
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]ShoppingItem, 0, len(totals))
|
items := make([]ShoppingItem, 0, len(totals))
|
||||||
for k, amount := range totals {
|
for k, amount := range totals {
|
||||||
items = append(items, ShoppingItem{
|
items = append(items, ShoppingItem{
|
||||||
Name: k.name,
|
Name: k.name,
|
||||||
Category: categories[k.name],
|
Category: "other",
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Unit: k.unit,
|
Unit: k.unit,
|
||||||
Checked: false,
|
Checked: false,
|
||||||
@@ -479,26 +467,70 @@ func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest {
|
// recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
|
||||||
ingJSON, _ := json.Marshal(r.Ingredients)
|
func recipeToCreateRequest(r gemini.Recipe) dish.CreateRequest {
|
||||||
stepsJSON, _ := json.Marshal(r.Steps)
|
cr := dish.CreateRequest{
|
||||||
tagsJSON, _ := json.Marshal(r.Tags)
|
Name: r.Title,
|
||||||
nutritionJSON, _ := json.Marshal(r.Nutrition)
|
|
||||||
return savedrecipe.SaveRequest{
|
|
||||||
Title: r.Title,
|
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
Cuisine: r.Cuisine,
|
CuisineSlug: mapCuisineSlug(r.Cuisine),
|
||||||
|
ImageURL: r.ImageURL,
|
||||||
Difficulty: r.Difficulty,
|
Difficulty: r.Difficulty,
|
||||||
PrepTimeMin: r.PrepTimeMin,
|
PrepTimeMin: r.PrepTimeMin,
|
||||||
CookTimeMin: r.CookTimeMin,
|
CookTimeMin: r.CookTimeMin,
|
||||||
Servings: r.Servings,
|
Servings: r.Servings,
|
||||||
ImageURL: r.ImageURL,
|
Calories: r.Nutrition.Calories,
|
||||||
Ingredients: ingJSON,
|
Protein: r.Nutrition.ProteinG,
|
||||||
Steps: stepsJSON,
|
Fat: r.Nutrition.FatG,
|
||||||
Tags: tagsJSON,
|
Carbs: r.Nutrition.CarbsG,
|
||||||
Nutrition: nutritionJSON,
|
|
||||||
Source: "menu",
|
Source: "menu",
|
||||||
}
|
}
|
||||||
|
for _, ing := range r.Ingredients {
|
||||||
|
cr.Ingredients = append(cr.Ingredients, dish.IngredientInput{
|
||||||
|
Name: ing.Name,
|
||||||
|
Amount: ing.Amount,
|
||||||
|
Unit: ing.Unit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, s := range r.Steps {
|
||||||
|
cr.Steps = append(cr.Steps, dish.StepInput{
|
||||||
|
Number: s.Number,
|
||||||
|
Description: s.Description,
|
||||||
|
TimerSeconds: s.TimerSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cr.Tags = append(cr.Tags, r.Tags...)
|
||||||
|
return cr
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||||
|
// Falls back to "other".
|
||||||
|
func mapCuisineSlug(cuisine string) string {
|
||||||
|
known := map[string]string{
|
||||||
|
"russian": "russian",
|
||||||
|
"italian": "italian",
|
||||||
|
"french": "french",
|
||||||
|
"chinese": "chinese",
|
||||||
|
"japanese": "japanese",
|
||||||
|
"korean": "korean",
|
||||||
|
"mexican": "mexican",
|
||||||
|
"mediterranean": "mediterranean",
|
||||||
|
"indian": "indian",
|
||||||
|
"thai": "thai",
|
||||||
|
"american": "american",
|
||||||
|
"georgian": "georgian",
|
||||||
|
"spanish": "spanish",
|
||||||
|
"german": "german",
|
||||||
|
"middle_eastern": "middle_eastern",
|
||||||
|
"turkish": "turkish",
|
||||||
|
"greek": "greek",
|
||||||
|
"vietnamese": "vietnamese",
|
||||||
|
"asian": "other",
|
||||||
|
"european": "other",
|
||||||
|
}
|
||||||
|
if s, ok := known[cuisine]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.
|
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -27,18 +28,28 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
|
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
|
||||||
// Returns nil, nil when no plan exists for that week.
|
// Returns nil, nil when no plan exists for that week.
|
||||||
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
|
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
SELECT mp.id, mp.week_start::text,
|
SELECT mp.id, mp.week_start::text,
|
||||||
mi.id, mi.day_of_week, mi.meal_type,
|
mi.id, mi.day_of_week, mi.meal_type,
|
||||||
sr.id, sr.title, COALESCE(sr.image_url, ''), sr.nutrition
|
rec.id,
|
||||||
|
COALESCE(dt.name, d.name),
|
||||||
|
COALESCE(d.image_url, ''),
|
||||||
|
rec.calories_per_serving,
|
||||||
|
rec.protein_per_serving,
|
||||||
|
rec.fat_per_serving,
|
||||||
|
rec.carbs_per_serving
|
||||||
FROM menu_plans mp
|
FROM menu_plans mp
|
||||||
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
||||||
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
LEFT JOIN recipes rec ON rec.id = mi.recipe_id
|
||||||
|
LEFT JOIN dishes d ON d.id = rec.dish_id
|
||||||
|
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||||
WHERE mp.user_id = $1 AND mp.week_start::text = $2
|
WHERE mp.user_id = $1 AND mp.week_start::text = $2
|
||||||
ORDER BY mi.day_of_week,
|
ORDER BY mi.day_of_week,
|
||||||
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
|
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
|
||||||
|
|
||||||
rows, err := r.pool.Query(ctx, q, userID, weekStart)
|
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get menu by week: %w", err)
|
return nil, fmt.Errorf("get menu by week: %w", err)
|
||||||
}
|
}
|
||||||
@@ -53,12 +64,13 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
|||||||
itemID, mealType *string
|
itemID, mealType *string
|
||||||
dow *int
|
dow *int
|
||||||
recipeID, title, imageURL *string
|
recipeID, title, imageURL *string
|
||||||
nutritionRaw []byte
|
calPer, protPer, fatPer, carbPer *float64
|
||||||
)
|
)
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&planID, &planWeekStart,
|
&planID, &planWeekStart,
|
||||||
&itemID, &dow, &mealType,
|
&itemID, &dow, &mealType,
|
||||||
&recipeID, &title, &imageURL, &nutritionRaw,
|
&recipeID, &title, &imageURL,
|
||||||
|
&calPer, &protPer, &fatPer, &carbPer,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan menu row: %w", err)
|
return nil, fmt.Errorf("scan menu row: %w", err)
|
||||||
}
|
}
|
||||||
@@ -79,9 +91,11 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
|||||||
|
|
||||||
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
||||||
if recipeID != nil && title != nil {
|
if recipeID != nil && title != nil {
|
||||||
var nutrition NutritionInfo
|
nutrition := NutritionInfo{
|
||||||
if len(nutritionRaw) > 0 {
|
Calories: derefFloat(calPer),
|
||||||
_ = json.Unmarshal(nutritionRaw, &nutrition)
|
ProteinG: derefFloat(protPer),
|
||||||
|
FatG: derefFloat(fatPer),
|
||||||
|
CarbsG: derefFloat(carbPer),
|
||||||
}
|
}
|
||||||
slot.Recipe = &MenuRecipe{
|
slot.Recipe = &MenuRecipe{
|
||||||
ID: *recipeID,
|
ID: *recipeID,
|
||||||
@@ -257,10 +271,12 @@ func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart stri
|
|||||||
// GetIngredientsByPlan returns all ingredients from all recipes in the plan.
|
// GetIngredientsByPlan returns all ingredients from all recipes in the plan.
|
||||||
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
|
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT sr.ingredients, sr.nutrition, mi.meal_type
|
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type
|
||||||
FROM menu_items mi
|
FROM menu_items mi
|
||||||
JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
JOIN recipes rec ON rec.id = mi.recipe_id
|
||||||
WHERE mi.menu_plan_id = $1`, planID)
|
JOIN recipe_ingredients ri ON ri.recipe_id = rec.id
|
||||||
|
WHERE mi.menu_plan_id = $1
|
||||||
|
ORDER BY ri.sort_order`, planID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get ingredients by plan: %w", err)
|
return nil, fmt.Errorf("get ingredients by plan: %w", err)
|
||||||
}
|
}
|
||||||
@@ -268,23 +284,19 @@ func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([
|
|||||||
|
|
||||||
var result []ingredientRow
|
var result []ingredientRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ingredientsRaw, nutritionRaw []byte
|
var row ingredientRow
|
||||||
var mealType string
|
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
|
||||||
if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result = append(result, ingredientRow{
|
result = append(result, row)
|
||||||
IngredientsJSON: ingredientsRaw,
|
|
||||||
NutritionJSON: nutritionRaw,
|
|
||||||
MealType: mealType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingredientRow struct {
|
type ingredientRow struct {
|
||||||
IngredientsJSON []byte
|
Name string
|
||||||
NutritionJSON []byte
|
Amount float64
|
||||||
|
UnitCode *string
|
||||||
MealType string
|
MealType string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,3 +316,10 @@ func derefStr(s *string) string {
|
|||||||
}
|
}
|
||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefFloat(f *float64) float64 {
|
||||||
|
if f == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *f
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "time"
|
|||||||
type Product struct {
|
type Product struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
MappingID *string `json:"mapping_id"`
|
PrimaryIngredientID *string `json:"primary_ingredient_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
@@ -20,6 +20,8 @@ type Product struct {
|
|||||||
|
|
||||||
// CreateRequest is the body for POST /products.
|
// CreateRequest is the body for POST /products.
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
|
PrimaryIngredientID *string `json:"primary_ingredient_id"`
|
||||||
|
// Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields.
|
||||||
MappingID *string `json:"mapping_id"`
|
MappingID *string `json:"mapping_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
|
|
||||||
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE),
|
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE),
|
||||||
// which prevents it from being used as a stored generated column.
|
// which prevents it from being used as a stored generated column.
|
||||||
const selectCols = `id, user_id, mapping_id, name, quantity, unit, category, storage_days, added_at,
|
const selectCols = `id, user_id, primary_ingredient_id, name, quantity, unit, category, storage_days, added_at,
|
||||||
(added_at + storage_days * INTERVAL '1 day') AS expires_at`
|
(added_at + storage_days * INTERVAL '1 day') AS expires_at`
|
||||||
|
|
||||||
// List returns all products for a user, sorted by expires_at ASC.
|
// List returns all products for a user, sorted by expires_at ASC.
|
||||||
@@ -57,11 +57,17 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
|||||||
qty = 1
|
qty = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accept both new and legacy field names.
|
||||||
|
primaryID := req.PrimaryIngredientID
|
||||||
|
if primaryID == nil {
|
||||||
|
primaryID = req.MappingID
|
||||||
|
}
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO products (user_id, mapping_id, name, quantity, unit, category, storage_days)
|
INSERT INTO products (user_id, primary_ingredient_id, name, quantity, unit, category, storage_days)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING `+selectCols,
|
RETURNING `+selectCols,
|
||||||
userID, req.MappingID, req.Name, qty, unit, req.Category, storageDays,
|
userID, primaryID, req.Name, qty, unit, req.Category, storageDays,
|
||||||
)
|
)
|
||||||
return scanProduct(row)
|
return scanProduct(row)
|
||||||
}
|
}
|
||||||
@@ -144,11 +150,11 @@ func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string
|
|||||||
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
|
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
|
||||||
switch {
|
switch {
|
||||||
case daysLeft <= 0:
|
case daysLeft <= 0:
|
||||||
line += " (истекает сегодня ⚠)"
|
line += " (expires today ⚠)"
|
||||||
case daysLeft == 1:
|
case daysLeft == 1:
|
||||||
line += " (истекает завтра ⚠)"
|
line += " (expires tomorrow ⚠)"
|
||||||
case daysLeft <= 3:
|
case daysLeft <= 3:
|
||||||
line += fmt.Sprintf(" (истекает через %d дня ⚠)", daysLeft)
|
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
@@ -160,7 +166,7 @@ func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string
|
|||||||
func scanProduct(row pgx.Row) (*Product, error) {
|
func scanProduct(row pgx.Row) (*Product, error) {
|
||||||
var p Product
|
var p Product
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
|
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
|
||||||
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,7 +181,7 @@ func collectProducts(rows pgx.Rows) ([]*Product, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Product
|
var p Product
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
|
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
|
||||||
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan product: %w", err)
|
return nil, fmt.Errorf("scan product: %w", err)
|
||||||
|
|||||||
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
|
package recipe
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Recipe is a recipe record in the database.
|
// Recipe is a cooking variant of a Dish in the catalog.
|
||||||
// Title, Description, Ingredients, and Steps hold the content for the language
|
// It links to a Dish for all presentational data (name, image, cuisine, tags).
|
||||||
// resolved at query time (English by default, or from recipe_translations when
|
|
||||||
// a matching row exists for the requested language).
|
|
||||||
type Recipe struct {
|
type Recipe struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Source string `json:"source"` // spoonacular | ai | user
|
DishID string `json:"dish_id"`
|
||||||
SpoonacularID *int `json:"spoonacular_id"`
|
Source string `json:"source"` // ai | user | spoonacular
|
||||||
|
|
||||||
Title string `json:"title"`
|
Difficulty *string `json:"difficulty"`
|
||||||
Description *string `json:"description"`
|
|
||||||
|
|
||||||
Cuisine *string `json:"cuisine"`
|
|
||||||
Difficulty *string `json:"difficulty"` // easy | medium | hard
|
|
||||||
PrepTimeMin *int `json:"prep_time_min"`
|
PrepTimeMin *int `json:"prep_time_min"`
|
||||||
CookTimeMin *int `json:"cook_time_min"`
|
CookTimeMin *int `json:"cook_time_min"`
|
||||||
Servings *int `json:"servings"`
|
Servings *int `json:"servings"`
|
||||||
ImageURL *string `json:"image_url"`
|
|
||||||
|
|
||||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||||
@@ -30,29 +20,29 @@ type Recipe struct {
|
|||||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||||
FiberPerServing *float64 `json:"fiber_per_serving"`
|
FiberPerServing *float64 `json:"fiber_per_serving"`
|
||||||
|
|
||||||
Ingredients json.RawMessage `json:"ingredients"` // []RecipeIngredient
|
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||||
Steps json.RawMessage `json:"steps"` // []RecipeStep
|
Steps []RecipeStep `json:"steps"`
|
||||||
Tags json.RawMessage `json:"tags"` // []string
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
|
||||||
AvgRating float64 `json:"avg_rating"`
|
|
||||||
ReviewCount int `json:"review_count"`
|
|
||||||
CreatedBy *string `json:"created_by"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipeIngredient is a single ingredient in a recipe's JSONB array.
|
// RecipeIngredient is a single ingredient row from recipe_ingredients.
|
||||||
type RecipeIngredient struct {
|
type RecipeIngredient struct {
|
||||||
MappingID *string `json:"mapping_id"`
|
ID string `json:"id"`
|
||||||
|
IngredientID *string `json:"ingredient_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
Unit string `json:"unit"`
|
UnitCode *string `json:"unit_code"`
|
||||||
Optional bool `json:"optional"`
|
IsOptional bool `json:"is_optional"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipeStep is a single step in a recipe's JSONB array.
|
// RecipeStep is a single step row from recipe_steps.
|
||||||
type RecipeStep struct {
|
type RecipeStep struct {
|
||||||
Number int `json:"number"`
|
ID string `json:"id"`
|
||||||
|
StepNumber int `json:"step_number"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
TimerSeconds *int `json:"timer_seconds"`
|
TimerSeconds *int `json:"timer_seconds"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package recipe
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository handles persistence for recipes and their translations.
|
// Repository handles persistence for recipes and their relational sub-tables.
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -21,77 +20,39 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
return &Repository{pool: pool}
|
return &Repository{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert inserts or updates a recipe (English canonical content only).
|
// GetByID returns a recipe with its ingredients and steps.
|
||||||
// Conflict is resolved on spoonacular_id.
|
// Text is resolved for the language stored in ctx (English fallback).
|
||||||
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
|
|
||||||
query := `
|
|
||||||
INSERT INTO recipes (
|
|
||||||
source, spoonacular_id,
|
|
||||||
title, description,
|
|
||||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
|
||||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
|
||||||
ingredients, steps, tags
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
|
||||||
ON CONFLICT (spoonacular_id) DO UPDATE SET
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
cuisine = EXCLUDED.cuisine,
|
|
||||||
difficulty = EXCLUDED.difficulty,
|
|
||||||
prep_time_min = EXCLUDED.prep_time_min,
|
|
||||||
cook_time_min = EXCLUDED.cook_time_min,
|
|
||||||
servings = EXCLUDED.servings,
|
|
||||||
image_url = EXCLUDED.image_url,
|
|
||||||
calories_per_serving = EXCLUDED.calories_per_serving,
|
|
||||||
protein_per_serving = EXCLUDED.protein_per_serving,
|
|
||||||
fat_per_serving = EXCLUDED.fat_per_serving,
|
|
||||||
carbs_per_serving = EXCLUDED.carbs_per_serving,
|
|
||||||
fiber_per_serving = EXCLUDED.fiber_per_serving,
|
|
||||||
ingredients = EXCLUDED.ingredients,
|
|
||||||
steps = EXCLUDED.steps,
|
|
||||||
tags = EXCLUDED.tags,
|
|
||||||
updated_at = now()
|
|
||||||
RETURNING id, source, spoonacular_id,
|
|
||||||
title, description,
|
|
||||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
|
||||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
|
||||||
ingredients, steps, tags,
|
|
||||||
avg_rating, review_count, created_by, created_at, updated_at`
|
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, query,
|
|
||||||
recipe.Source, recipe.SpoonacularID,
|
|
||||||
recipe.Title, recipe.Description,
|
|
||||||
recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
|
|
||||||
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
|
|
||||||
recipe.Ingredients, recipe.Steps, recipe.Tags,
|
|
||||||
)
|
|
||||||
return scanRecipe(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID returns a recipe by UUID, with content resolved for the language
|
|
||||||
// stored in ctx (falls back to English when no translation exists).
|
|
||||||
// Returns nil, nil if not found.
|
// Returns nil, nil if not found.
|
||||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
|
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
|
||||||
lang := locale.FromContext(ctx)
|
lang := locale.FromContext(ctx)
|
||||||
query := `
|
|
||||||
SELECT r.id, r.source, r.spoonacular_id,
|
const q = `
|
||||||
COALESCE(rt.title, r.title) AS title,
|
SELECT r.id, r.dish_id, r.source, r.difficulty,
|
||||||
COALESCE(rt.description, r.description) AS description,
|
r.prep_time_min, r.cook_time_min, r.servings,
|
||||||
r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url,
|
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving,
|
||||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving,
|
r.carbs_per_serving, r.fiber_per_serving,
|
||||||
COALESCE(rt.ingredients, r.ingredients) AS ingredients,
|
rt.notes,
|
||||||
COALESCE(rt.steps, r.steps) AS steps,
|
r.created_at, r.updated_at
|
||||||
r.tags,
|
|
||||||
r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at
|
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
|
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
|
||||||
WHERE r.id = $1`
|
WHERE r.id = $1`
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, query, id, lang)
|
row := r.pool.QueryRow(ctx, q, id, lang)
|
||||||
rec, err := scanRecipe(row)
|
rec, err := scanRecipe(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return rec, err
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get recipe %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the total number of recipes.
|
// Count returns the total number of recipes.
|
||||||
@@ -103,97 +64,79 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMissingTranslation returns Spoonacular recipes that have no translation
|
// loadIngredients fills rec.Ingredients from recipe_ingredients.
|
||||||
// for the given language, ordered by review_count DESC.
|
func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
|
||||||
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) {
|
rows, err := r.pool.Query(ctx, `
|
||||||
query := `
|
SELECT ri.id, ri.ingredient_id,
|
||||||
SELECT id, source, spoonacular_id,
|
COALESCE(rit.name, ri.name) AS name,
|
||||||
title, description,
|
ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
|
||||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
FROM recipe_ingredients ri
|
||||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
LEFT JOIN recipe_ingredient_translations rit
|
||||||
ingredients, steps, tags,
|
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||||
avg_rating, review_count, created_by, created_at, updated_at
|
WHERE ri.recipe_id = $1
|
||||||
FROM recipes
|
ORDER BY ri.sort_order`, rec.ID, lang)
|
||||||
WHERE source = 'spoonacular'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM recipe_translations rt
|
|
||||||
WHERE rt.recipe_id = recipes.id AND rt.lang = $3
|
|
||||||
)
|
|
||||||
ORDER BY review_count DESC
|
|
||||||
LIMIT $1 OFFSET $2`
|
|
||||||
|
|
||||||
rows, err := r.pool.Query(ctx, query, limit, offset, lang)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
|
return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
return collectRecipes(rows)
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertTranslation inserts or replaces a recipe translation for a specific language.
|
// loadSteps fills rec.Steps from recipe_steps.
|
||||||
func (r *Repository) UpsertTranslation(
|
func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error {
|
||||||
ctx context.Context,
|
rows, err := r.pool.Query(ctx, `
|
||||||
id, lang string,
|
SELECT rs.id, rs.step_number,
|
||||||
title, description *string,
|
COALESCE(rst.description, rs.description) AS description,
|
||||||
ingredients, steps json.RawMessage,
|
rs.timer_seconds, rs.image_url
|
||||||
) error {
|
FROM recipe_steps rs
|
||||||
query := `
|
LEFT JOIN recipe_step_translations rst
|
||||||
INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps)
|
ON rst.step_id = rs.id AND rst.lang = $2
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
WHERE rs.recipe_id = $1
|
||||||
ON CONFLICT (recipe_id, lang) DO UPDATE SET
|
ORDER BY rs.step_number`, rec.ID, lang)
|
||||||
title = EXCLUDED.title,
|
if err != nil {
|
||||||
description = EXCLUDED.description,
|
return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err)
|
||||||
ingredients = EXCLUDED.ingredients,
|
|
||||||
steps = EXCLUDED.steps`
|
|
||||||
|
|
||||||
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
|
|
||||||
return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err)
|
|
||||||
}
|
}
|
||||||
return nil
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- scan helpers ---
|
||||||
|
|
||||||
func scanRecipe(row pgx.Row) (*Recipe, error) {
|
func scanRecipe(row pgx.Row) (*Recipe, error) {
|
||||||
var rec Recipe
|
var rec Recipe
|
||||||
var ingredients, steps, tags []byte
|
|
||||||
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
&rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
|
||||||
&rec.Title, &rec.Description,
|
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
|
||||||
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
|
||||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
&rec.CarbsPerServing, &rec.FiberPerServing,
|
||||||
&ingredients, &steps, &tags,
|
&rec.Notes,
|
||||||
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
|
&rec.CreatedAt, &rec.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rec.Ingredients = json.RawMessage(ingredients)
|
rec.Ingredients = []RecipeIngredient{}
|
||||||
rec.Steps = json.RawMessage(steps)
|
rec.Steps = []RecipeStep{}
|
||||||
rec.Tags = json.RawMessage(tags)
|
|
||||||
return &rec, nil
|
return &rec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
|
|
||||||
var result []*Recipe
|
|
||||||
for rows.Next() {
|
|
||||||
var rec Recipe
|
|
||||||
var ingredients, steps, tags []byte
|
|
||||||
if err := rows.Scan(
|
|
||||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
|
||||||
&rec.Title, &rec.Description,
|
|
||||||
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
|
||||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
|
||||||
&ingredients, &steps, &tags,
|
|
||||||
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan recipe: %w", err)
|
|
||||||
}
|
|
||||||
rec.Ingredients = json.RawMessage(ingredients)
|
|
||||||
rec.Steps = json.RawMessage(steps)
|
|
||||||
rec.Tags = json.RawMessage(tags)
|
|
||||||
result = append(result, &rec)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,132 +4,11 @@ package recipe
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/locale"
|
|
||||||
"github.com/food-ai/backend/internal/testutil"
|
"github.com/food-ai/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecipeRepository_Upsert_Insert(t *testing.T) {
|
|
||||||
pool := testutil.SetupTestDB(t)
|
|
||||||
repo := NewRepository(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
id := 10001
|
|
||||||
cuisine := "italian"
|
|
||||||
diff := "easy"
|
|
||||||
cookTime := 30
|
|
||||||
servings := 4
|
|
||||||
|
|
||||||
rec := &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &id,
|
|
||||||
Title: "Pasta Carbonara",
|
|
||||||
Cuisine: &cuisine,
|
|
||||||
Difficulty: &diff,
|
|
||||||
CookTimeMin: &cookTime,
|
|
||||||
Servings: &servings,
|
|
||||||
Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`),
|
|
||||||
Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`),
|
|
||||||
Tags: json.RawMessage(`["italian"]`),
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := repo.Upsert(ctx, rec)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID == "" {
|
|
||||||
t.Error("expected non-empty ID")
|
|
||||||
}
|
|
||||||
if got.Title != "Pasta Carbonara" {
|
|
||||||
t.Errorf("title: want Pasta Carbonara, got %s", got.Title)
|
|
||||||
}
|
|
||||||
if got.SpoonacularID == nil || *got.SpoonacularID != id {
|
|
||||||
t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) {
|
|
||||||
pool := testutil.SetupTestDB(t)
|
|
||||||
repo := NewRepository(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
id := 20001
|
|
||||||
cuisine := "mexican"
|
|
||||||
diff := "medium"
|
|
||||||
|
|
||||||
first := &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &id,
|
|
||||||
Title: "Tacos",
|
|
||||||
Cuisine: &cuisine,
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`[]`),
|
|
||||||
}
|
|
||||||
got1, err := repo.Upsert(ctx, first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first upsert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
second := &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &id,
|
|
||||||
Title: "Beef Tacos",
|
|
||||||
Cuisine: &cuisine,
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`[]`),
|
|
||||||
}
|
|
||||||
got2, err := repo.Upsert(ctx, second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second upsert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got1.ID != got2.ID {
|
|
||||||
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
|
|
||||||
}
|
|
||||||
if got2.Title != "Beef Tacos" {
|
|
||||||
t.Errorf("title not updated: got %s", got2.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecipeRepository_GetByID_Found(t *testing.T) {
|
|
||||||
pool := testutil.SetupTestDB(t)
|
|
||||||
repo := NewRepository(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
id := 30001
|
|
||||||
diff := "easy"
|
|
||||||
rec := &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &id,
|
|
||||||
Title: "Greek Salad",
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`["vegetarian"]`),
|
|
||||||
}
|
|
||||||
saved, err := repo.Upsert(ctx, rec)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := repo.GetByID(ctx, saved.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get by id: %v", err)
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("expected non-nil result")
|
|
||||||
}
|
|
||||||
if got.Title != "Greek Salad" {
|
|
||||||
t.Errorf("want Greek Salad, got %s", got.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
|
func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
|
||||||
pool := testutil.SetupTestDB(t)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
@@ -144,181 +23,13 @@ func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecipeRepository_ListMissingTranslation_Pagination(t *testing.T) {
|
func TestRecipeRepository_Count(t *testing.T) {
|
||||||
pool := testutil.SetupTestDB(t)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
diff := "easy"
|
_, err := repo.Count(ctx)
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
spID := 40000 + i
|
|
||||||
_, err := repo.Upsert(ctx, &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &spID,
|
|
||||||
Title: "Recipe " + string(rune('A'+i)),
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`[]`),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("upsert recipe %d: %v", i, err)
|
t.Fatalf("count: %v", err)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list missing translation: %v", err)
|
|
||||||
}
|
|
||||||
if len(missing) != 3 {
|
|
||||||
t.Errorf("expected 3 results with limit=3, got %d", len(missing))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecipeRepository_UpsertTranslation(t *testing.T) {
|
|
||||||
pool := testutil.SetupTestDB(t)
|
|
||||||
repo := NewRepository(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
id := 50001
|
|
||||||
diff := "medium"
|
|
||||||
saved, err := repo.Upsert(ctx, &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &id,
|
|
||||||
Title: "Chicken Tikka Masala",
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`),
|
|
||||||
Tags: json.RawMessage(`[]`),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
titleRu := "Курица Тикка Масала"
|
|
||||||
descRu := "Классическое индийское блюдо"
|
|
||||||
stepsRu := json.RawMessage(`[{"number":1,"description":"Разогрейте масло"}]`)
|
|
||||||
|
|
||||||
if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); err != nil {
|
|
||||||
t.Fatalf("upsert translation: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve with Russian context — title and steps should be translated.
|
|
||||||
ruCtx := locale.WithLang(ctx, "ru")
|
|
||||||
got, err := repo.GetByID(ruCtx, saved.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get by id: %v", err)
|
|
||||||
}
|
|
||||||
if got.Title != titleRu {
|
|
||||||
t.Errorf("expected title=%q, got %q", titleRu, got.Title)
|
|
||||||
}
|
|
||||||
if got.Description == nil || *got.Description != descRu {
|
|
||||||
t.Errorf("expected description=%q, got %v", descRu, got.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
var steps []RecipeStep
|
|
||||||
if err := json.Unmarshal(got.Steps, &steps); err != nil {
|
|
||||||
t.Fatalf("unmarshal steps: %v", err)
|
|
||||||
}
|
|
||||||
if len(steps) == 0 || steps[0].Description != "Разогрейте масло" {
|
|
||||||
t.Errorf("expected Russian step description, got %v", steps)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve with English context — should return original English content.
|
|
||||||
enCtx := locale.WithLang(ctx, "en")
|
|
||||||
gotEn, err := repo.GetByID(enCtx, saved.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get by id (en): %v", err)
|
|
||||||
}
|
|
||||||
if gotEn.Title != "Chicken Tikka Masala" {
|
|
||||||
t.Errorf("expected English title, got %q", gotEn.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecipeRepository_ListMissingTranslation_ExcludesTranslated(t *testing.T) {
|
|
||||||
pool := testutil.SetupTestDB(t)
|
|
||||||
repo := NewRepository(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
diff := "easy"
|
|
||||||
|
|
||||||
// Insert untranslated recipes.
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
spID := 60000 + i
|
|
||||||
_, err := repo.Upsert(ctx, &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &spID,
|
|
||||||
Title: "Untranslated " + string(rune('A'+i)),
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`[]`),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert one recipe and add a Russian translation.
|
|
||||||
spID := 60100
|
|
||||||
translated, err := repo.Upsert(ctx, &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &spID,
|
|
||||||
Title: "Translated Recipe",
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`[]`),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert translated: %v", err)
|
|
||||||
}
|
|
||||||
titleRu := "Переведённый рецепт"
|
|
||||||
if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil {
|
|
||||||
t.Fatalf("upsert translation: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list missing translation: %v", err)
|
|
||||||
}
|
|
||||||
for _, r := range missing {
|
|
||||||
if r.Title == "Translated Recipe" {
|
|
||||||
t.Error("translated recipe should not appear in ListMissingTranslation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(missing) < 3 {
|
|
||||||
t.Errorf("expected at least 3 missing, got %d", len(missing))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecipeRepository_GIN_Tags(t *testing.T) {
|
|
||||||
pool := testutil.SetupTestDB(t)
|
|
||||||
repo := NewRepository(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
id := 70001
|
|
||||||
diff := "easy"
|
|
||||||
_, err := repo.Upsert(ctx, &Recipe{
|
|
||||||
Source: "spoonacular",
|
|
||||||
SpoonacularID: &id,
|
|
||||||
Title: "Veggie Bowl",
|
|
||||||
Difficulty: &diff,
|
|
||||||
Ingredients: json.RawMessage(`[]`),
|
|
||||||
Steps: json.RawMessage(`[]`),
|
|
||||||
Tags: json.RawMessage(`["vegetarian","gluten-free"]`),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GIN index query: tags @> '["vegetarian"]'
|
|
||||||
var count int
|
|
||||||
row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id)
|
|
||||||
if err := row.Scan(&count); err != nil {
|
|
||||||
t.Fatalf("query: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("expected 1 vegetarian recipe, got %d", count)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Title == "" {
|
if req.Title == "" && req.RecipeID == "" {
|
||||||
writeErrorJSON(w, http.StatusBadRequest, "title is required")
|
writeErrorJSON(w, http.StatusBadRequest, "title or recipe_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +64,8 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
|
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if recipes == nil {
|
if recipes == nil {
|
||||||
recipes = []*SavedRecipe{}
|
recipes = []*UserSavedRecipe{}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, recipes)
|
writeJSON(w, http.StatusOK, recipes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,56 @@
|
|||||||
package savedrecipe
|
package savedrecipe
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SavedRecipe is a recipe saved by a specific user.
|
// UserSavedRecipe is a user's bookmark referencing a catalog recipe.
|
||||||
type SavedRecipe struct {
|
// Display fields are populated by joining dishes + recipes.
|
||||||
|
type UserSavedRecipe struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"-"`
|
UserID string `json:"-"`
|
||||||
Title string `json:"title"`
|
RecipeID string `json:"recipe_id"`
|
||||||
|
SavedAt time.Time `json:"saved_at"`
|
||||||
|
|
||||||
|
// Display data — joined from dishes + recipes.
|
||||||
|
DishName string `json:"title"` // dish name used as display title
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Cuisine *string `json:"cuisine"`
|
ImageURL *string `json:"image_url"`
|
||||||
|
CuisineSlug *string `json:"cuisine_slug"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
|
||||||
Difficulty *string `json:"difficulty"`
|
Difficulty *string `json:"difficulty"`
|
||||||
PrepTimeMin *int `json:"prep_time_min"`
|
PrepTimeMin *int `json:"prep_time_min"`
|
||||||
CookTimeMin *int `json:"cook_time_min"`
|
CookTimeMin *int `json:"cook_time_min"`
|
||||||
Servings *int `json:"servings"`
|
Servings *int `json:"servings"`
|
||||||
ImageURL *string `json:"image_url"`
|
|
||||||
Ingredients json.RawMessage `json:"ingredients"`
|
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||||
Steps json.RawMessage `json:"steps"`
|
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||||
Tags json.RawMessage `json:"tags"`
|
FatPerServing *float64 `json:"fat_per_serving"`
|
||||||
Nutrition json.RawMessage `json:"nutrition_per_serving"`
|
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||||
Source string `json:"source"`
|
|
||||||
SavedAt time.Time `json:"saved_at"`
|
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||||
|
Steps []RecipeStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipeIngredient is a single ingredient row.
|
||||||
|
type RecipeIngredient struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
UnitCode *string `json:"unit_code"`
|
||||||
|
IsOptional bool `json:"is_optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipeStep is a single step row.
|
||||||
|
type RecipeStep struct {
|
||||||
|
StepNumber int `json:"number"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TimerSeconds *int `json:"timer_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRequest is the body for POST /saved-recipes.
|
// SaveRequest is the body for POST /saved-recipes.
|
||||||
|
// When recipe_id is provided, the existing catalog recipe is bookmarked.
|
||||||
|
// Otherwise a new dish+recipe is created from the supplied fields.
|
||||||
type SaveRequest struct {
|
type SaveRequest struct {
|
||||||
|
RecipeID string `json:"recipe_id"` // optional: bookmark existing recipe
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Cuisine string `json:"cuisine"`
|
Cuisine string `json:"cuisine"`
|
||||||
@@ -35,9 +59,18 @@ type SaveRequest struct {
|
|||||||
CookTimeMin int `json:"cook_time_min"`
|
CookTimeMin int `json:"cook_time_min"`
|
||||||
Servings int `json:"servings"`
|
Servings int `json:"servings"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
Ingredients json.RawMessage `json:"ingredients"`
|
// Ingredients / Steps / Tags / Nutrition are JSONB for backward compatibility
|
||||||
Steps json.RawMessage `json:"steps"`
|
// with the recommendation flow that sends the full Gemini response.
|
||||||
Tags json.RawMessage `json:"tags"`
|
Ingredients interface{} `json:"ingredients"`
|
||||||
Nutrition json.RawMessage `json:"nutrition_per_serving"`
|
Steps interface{} `json:"steps"`
|
||||||
|
Tags interface{} `json:"tags"`
|
||||||
|
Nutrition interface{} `json:"nutrition_per_serving"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
||||||
|
var ErrNotFound = errorString("saved recipe not found")
|
||||||
|
|
||||||
|
type errorString string
|
||||||
|
|
||||||
|
func (e errorString) Error() string { return string(e) }
|
||||||
|
|||||||
@@ -6,132 +6,244 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/dish"
|
||||||
"github.com/food-ai/backend/internal/locale"
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
// Repository handles persistence for user_saved_recipes.
|
||||||
var ErrNotFound = errors.New("saved recipe not found")
|
|
||||||
|
|
||||||
// Repository handles persistence for saved recipes and their translations.
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
dishRepo *dish.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRepository creates a new Repository.
|
// NewRepository creates a new Repository.
|
||||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository {
|
||||||
return &Repository{pool: pool}
|
return &Repository{pool: pool, dishRepo: dishRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save persists a recipe for userID and returns the stored record.
|
// Save bookmarks a recipe for the user.
|
||||||
// The canonical content (any language) is stored directly in saved_recipes.
|
// If req.RecipeID is set, that existing catalog recipe is bookmarked.
|
||||||
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
|
// Otherwise a new dish + recipe is created from the supplied fields.
|
||||||
const query = `
|
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) {
|
||||||
INSERT INTO saved_recipes (
|
recipeID := req.RecipeID
|
||||||
user_id, title, description, cuisine, difficulty,
|
|
||||||
prep_time_min, cook_time_min, servings, image_url,
|
|
||||||
ingredients, steps, tags, nutrition, source
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
||||||
RETURNING id, user_id, title, description, cuisine, difficulty,
|
|
||||||
prep_time_min, cook_time_min, servings, image_url,
|
|
||||||
ingredients, steps, tags, nutrition, source, saved_at`
|
|
||||||
|
|
||||||
description := nullableStr(req.Description)
|
if recipeID == "" {
|
||||||
cuisine := nullableStr(req.Cuisine)
|
// Build a dish.CreateRequest from the save body.
|
||||||
difficulty := nullableStr(req.Difficulty)
|
cr := dish.CreateRequest{
|
||||||
imageURL := nullableStr(req.ImageURL)
|
Name: req.Title,
|
||||||
prepTime := nullableInt(req.PrepTimeMin)
|
Description: req.Description,
|
||||||
cookTime := nullableInt(req.CookTimeMin)
|
CuisineSlug: mapCuisineSlug(req.Cuisine),
|
||||||
servings := nullableInt(req.Servings)
|
ImageURL: req.ImageURL,
|
||||||
|
Source: req.Source,
|
||||||
source := req.Source
|
Difficulty: req.Difficulty,
|
||||||
if source == "" {
|
PrepTimeMin: req.PrepTimeMin,
|
||||||
source = "ai"
|
CookTimeMin: req.CookTimeMin,
|
||||||
|
Servings: req.Servings,
|
||||||
}
|
}
|
||||||
|
|
||||||
ingredients := defaultJSONArray(req.Ingredients)
|
// Unmarshal ingredients.
|
||||||
steps := defaultJSONArray(req.Steps)
|
if req.Ingredients != nil {
|
||||||
tags := defaultJSONArray(req.Tags)
|
switch v := req.Ingredients.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for i, item := range v {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ing := dish.IngredientInput{
|
||||||
|
Name: strVal(m["name"]),
|
||||||
|
Amount: floatVal(m["amount"]),
|
||||||
|
Unit: strVal(m["unit"]),
|
||||||
|
}
|
||||||
|
cr.Ingredients = append(cr.Ingredients, ing)
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
case json.RawMessage:
|
||||||
|
var items []dish.IngredientInput
|
||||||
|
if err := json.Unmarshal(v, &items); err == nil {
|
||||||
|
cr.Ingredients = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, query,
|
// Unmarshal steps.
|
||||||
userID, req.Title, description, cuisine, difficulty,
|
if req.Steps != nil {
|
||||||
prepTime, cookTime, servings, imageURL,
|
switch v := req.Steps.(type) {
|
||||||
ingredients, steps, tags, req.Nutrition, source,
|
case []interface{}:
|
||||||
|
for i, item := range v {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
num := int(floatVal(m["number"]))
|
||||||
|
if num <= 0 {
|
||||||
|
num = i + 1
|
||||||
|
}
|
||||||
|
step := dish.StepInput{
|
||||||
|
Number: num,
|
||||||
|
Description: strVal(m["description"]),
|
||||||
|
}
|
||||||
|
cr.Steps = append(cr.Steps, step)
|
||||||
|
}
|
||||||
|
case json.RawMessage:
|
||||||
|
var items []dish.StepInput
|
||||||
|
if err := json.Unmarshal(v, &items); err == nil {
|
||||||
|
cr.Steps = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal tags.
|
||||||
|
if req.Tags != nil {
|
||||||
|
switch v := req.Tags.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for _, t := range v {
|
||||||
|
if s, ok := t.(string); ok {
|
||||||
|
cr.Tags = append(cr.Tags, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case json.RawMessage:
|
||||||
|
var items []string
|
||||||
|
if err := json.Unmarshal(v, &items); err == nil {
|
||||||
|
cr.Tags = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal nutrition.
|
||||||
|
if req.Nutrition != nil {
|
||||||
|
switch v := req.Nutrition.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
cr.Calories = floatVal(v["calories"])
|
||||||
|
cr.Protein = floatVal(v["protein_g"])
|
||||||
|
cr.Fat = floatVal(v["fat_g"])
|
||||||
|
cr.Carbs = floatVal(v["carbs_g"])
|
||||||
|
case json.RawMessage:
|
||||||
|
var nut struct {
|
||||||
|
Calories float64 `json:"calories"`
|
||||||
|
Protein float64 `json:"protein_g"`
|
||||||
|
Fat float64 `json:"fat_g"`
|
||||||
|
Carbs float64 `json:"carbs_g"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(v, &nut); err == nil {
|
||||||
|
cr.Calories = nut.Calories
|
||||||
|
cr.Protein = nut.Protein
|
||||||
|
cr.Fat = nut.Fat
|
||||||
|
cr.Carbs = nut.Carbs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
recipeID, err = r.dishRepo.Create(ctx, cr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create dish+recipe: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert bookmark.
|
||||||
|
const q = `
|
||||||
|
INSERT INTO user_saved_recipes (user_id, recipe_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id, recipe_id) DO UPDATE SET saved_at = now()
|
||||||
|
RETURNING id, user_id, recipe_id, saved_at`
|
||||||
|
|
||||||
|
var usr UserSavedRecipe
|
||||||
|
err := r.pool.QueryRow(ctx, q, userID, recipeID).Scan(
|
||||||
|
&usr.ID, &usr.UserID, &usr.RecipeID, &usr.SavedAt,
|
||||||
)
|
)
|
||||||
return scanRow(row)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert bookmark: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all saved recipes for userID ordered by saved_at DESC.
|
return r.enrichOne(ctx, &usr)
|
||||||
// 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) {
|
|
||||||
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`
|
|
||||||
|
|
||||||
rows, err := r.pool.Query(ctx, query, userID, lang)
|
// 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 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, q, userID, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list saved recipes: %w", err)
|
return nil, fmt.Errorf("list saved recipes: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var result []*SavedRecipe
|
var result []*UserSavedRecipe
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
rec, err := scanRow(rows)
|
rec, err := scanUSR(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scan saved recipe: %w", err)
|
return nil, fmt.Errorf("scan saved recipe: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := r.loadTags(ctx, rec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
result = append(result, rec)
|
result = append(result, rec)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID returns the saved recipe with id for userID, or nil if not found.
|
// GetByID returns a bookmarked recipe by its bookmark ID for userID.
|
||||||
// Text content is resolved for the language stored in ctx.
|
// Returns nil, nil if not found.
|
||||||
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
|
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) {
|
||||||
lang := locale.FromContext(ctx)
|
lang := locale.FromContext(ctx)
|
||||||
const query = `
|
const q = `
|
||||||
SELECT sr.id, sr.user_id,
|
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
|
||||||
COALESCE(srt.title, sr.title) AS title,
|
COALESCE(dt.name, d.name) AS dish_name,
|
||||||
COALESCE(srt.description, sr.description) AS description,
|
COALESCE(dt.description, d.description) AS description,
|
||||||
sr.cuisine, sr.difficulty,
|
d.image_url, d.cuisine_slug,
|
||||||
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
|
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
|
||||||
COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
|
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
|
||||||
COALESCE(srt.steps, sr.steps) AS steps,
|
FROM user_saved_recipes usr
|
||||||
sr.tags, sr.nutrition, sr.source, sr.saved_at
|
JOIN recipes r ON r.id = usr.recipe_id
|
||||||
FROM saved_recipes sr
|
JOIN dishes d ON d.id = r.dish_id
|
||||||
LEFT JOIN saved_recipe_translations srt
|
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||||
ON srt.saved_recipe_id = sr.id AND srt.lang = $3
|
WHERE usr.id = $1 AND usr.user_id = $2`
|
||||||
WHERE sr.id = $1 AND sr.user_id = $2`
|
|
||||||
|
|
||||||
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID, lang))
|
rec, err := scanUSR(r.pool.QueryRow(ctx, q, id, userID, lang))
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return rec, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadTags(ctx, rec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes the saved recipe with id for userID.
|
// Delete removes a bookmark.
|
||||||
// Returns ErrNotFound if the record does not exist.
|
|
||||||
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
||||||
tag, err := r.pool.Exec(ctx,
|
tag, err := r.pool.Exec(ctx,
|
||||||
`DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`,
|
`DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
id, userID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete saved recipe: %w", err)
|
return fmt.Errorf("delete saved recipe: %w", err)
|
||||||
}
|
}
|
||||||
@@ -141,73 +253,170 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertTranslation inserts or replaces a translation for a saved recipe.
|
|
||||||
func (r *Repository) UpsertTranslation(
|
|
||||||
ctx context.Context,
|
|
||||||
id, lang string,
|
|
||||||
title, description *string,
|
|
||||||
ingredients, steps json.RawMessage,
|
|
||||||
) error {
|
|
||||||
const query = `
|
|
||||||
INSERT INTO saved_recipe_translations (saved_recipe_id, lang, title, description, ingredients, steps)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (saved_recipe_id, lang) DO UPDATE SET
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
ingredients = EXCLUDED.ingredients,
|
|
||||||
steps = EXCLUDED.steps,
|
|
||||||
generated_at = now()`
|
|
||||||
|
|
||||||
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
|
|
||||||
return fmt.Errorf("upsert saved recipe translation %s/%s: %w", id, lang, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
type scannable interface {
|
func (r *Repository) enrichOne(ctx context.Context, usr *UserSavedRecipe) (*UserSavedRecipe, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
|
const q = `
|
||||||
|
SELECT COALESCE(dt.name, d.name) AS dish_name,
|
||||||
|
COALESCE(dt.description, d.description) AS description,
|
||||||
|
d.image_url, d.cuisine_slug,
|
||||||
|
rec.difficulty, rec.prep_time_min, rec.cook_time_min, rec.servings,
|
||||||
|
rec.calories_per_serving, rec.protein_per_serving,
|
||||||
|
rec.fat_per_serving, rec.carbs_per_serving
|
||||||
|
FROM recipes rec
|
||||||
|
JOIN dishes d ON d.id = rec.dish_id
|
||||||
|
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||||
|
WHERE rec.id = $1`
|
||||||
|
|
||||||
|
row := r.pool.QueryRow(ctx, q, usr.RecipeID, lang)
|
||||||
|
if err := row.Scan(
|
||||||
|
&usr.DishName, &usr.Description, &usr.ImageURL, &usr.CuisineSlug,
|
||||||
|
&usr.Difficulty, &usr.PrepTimeMin, &usr.CookTimeMin, &usr.Servings,
|
||||||
|
&usr.CaloriesPerServing, &usr.ProteinPerServing, &usr.FatPerServing, &usr.CarbsPerServing,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("enrich saved recipe: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.loadTags(ctx, usr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.loadIngredients(ctx, usr, lang); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return usr, r.loadSteps(ctx, usr, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadTags(ctx context.Context, usr *UserSavedRecipe) error {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT dt.tag_slug
|
||||||
|
FROM dish_tags dt
|
||||||
|
JOIN recipes rec ON rec.dish_id = dt.dish_id
|
||||||
|
JOIN user_saved_recipes usr ON usr.recipe_id = rec.id
|
||||||
|
WHERE usr.id = $1
|
||||||
|
ORDER BY dt.tag_slug`, usr.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load tags for saved recipe %s: %w", usr.ID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
usr.Tags = []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var slug string
|
||||||
|
if err := rows.Scan(&slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usr.Tags = append(usr.Tags, slug)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadIngredients(ctx context.Context, usr *UserSavedRecipe, lang string) error {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT COALESCE(rit.name, ri.name) AS name,
|
||||||
|
ri.amount, ri.unit_code, ri.is_optional
|
||||||
|
FROM recipe_ingredients ri
|
||||||
|
LEFT JOIN recipe_ingredient_translations rit
|
||||||
|
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||||
|
WHERE ri.recipe_id = $1
|
||||||
|
ORDER BY ri.sort_order`, usr.RecipeID, lang)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load ingredients for saved recipe %s: %w", usr.ID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
usr.Ingredients = []RecipeIngredient{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ing RecipeIngredient
|
||||||
|
if err := rows.Scan(&ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usr.Ingredients = append(usr.Ingredients, ing)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadSteps(ctx context.Context, usr *UserSavedRecipe, lang string) error {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT rs.step_number,
|
||||||
|
COALESCE(rst.description, rs.description) AS description,
|
||||||
|
rs.timer_seconds
|
||||||
|
FROM recipe_steps rs
|
||||||
|
LEFT JOIN recipe_step_translations rst
|
||||||
|
ON rst.step_id = rs.id AND rst.lang = $2
|
||||||
|
WHERE rs.recipe_id = $1
|
||||||
|
ORDER BY rs.step_number`, usr.RecipeID, lang)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load steps for saved recipe %s: %w", usr.ID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
usr.Steps = []RecipeStep{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s RecipeStep
|
||||||
|
if err := rows.Scan(&s.StepNumber, &s.Description, &s.TimerSeconds); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usr.Steps = append(usr.Steps, s)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanRow(s scannable) (*SavedRecipe, error) {
|
func scanUSR(s rowScanner) (*UserSavedRecipe, error) {
|
||||||
var rec SavedRecipe
|
var r UserSavedRecipe
|
||||||
var ingredients, steps, tags, nutrition []byte
|
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty,
|
&r.ID, &r.UserID, &r.RecipeID, &r.SavedAt,
|
||||||
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
&r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug,
|
||||||
&ingredients, &steps, &tags, &nutrition,
|
&r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings,
|
||||||
&rec.Source, &rec.SavedAt,
|
&r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing,
|
||||||
)
|
)
|
||||||
if err != nil {
|
return &r, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rec.Ingredients = json.RawMessage(ingredients)
|
|
||||||
rec.Steps = json.RawMessage(steps)
|
|
||||||
rec.Tags = json.RawMessage(tags)
|
|
||||||
if len(nutrition) > 0 {
|
|
||||||
rec.Nutrition = json.RawMessage(nutrition)
|
|
||||||
}
|
|
||||||
return &rec, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullableStr(s string) *string {
|
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||||
if s == "" {
|
// Falls back to "other".
|
||||||
return nil
|
func mapCuisineSlug(cuisine string) string {
|
||||||
|
known := map[string]string{
|
||||||
|
"russian": "russian",
|
||||||
|
"italian": "italian",
|
||||||
|
"french": "french",
|
||||||
|
"chinese": "chinese",
|
||||||
|
"japanese": "japanese",
|
||||||
|
"korean": "korean",
|
||||||
|
"mexican": "mexican",
|
||||||
|
"mediterranean": "mediterranean",
|
||||||
|
"indian": "indian",
|
||||||
|
"thai": "thai",
|
||||||
|
"american": "american",
|
||||||
|
"georgian": "georgian",
|
||||||
|
"spanish": "spanish",
|
||||||
|
"german": "german",
|
||||||
|
"middle_eastern":"middle_eastern",
|
||||||
|
"turkish": "turkish",
|
||||||
|
"greek": "greek",
|
||||||
|
"vietnamese": "vietnamese",
|
||||||
|
"asian": "other",
|
||||||
|
"european": "other",
|
||||||
}
|
}
|
||||||
return &s
|
if s, ok := known[cuisine]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullableInt(n int) *int {
|
func strVal(v interface{}) string {
|
||||||
if n <= 0 {
|
if s, ok := v.(string); ok {
|
||||||
return nil
|
return s
|
||||||
}
|
}
|
||||||
return &n
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultJSONArray(raw json.RawMessage) json.RawMessage {
|
func floatVal(v interface{}) float64 {
|
||||||
if len(raw) == 0 {
|
switch n := v.(type) {
|
||||||
return json.RawMessage(`[]`)
|
case float64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
}
|
}
|
||||||
return raw
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/auth"
|
"github.com/food-ai/backend/internal/auth"
|
||||||
|
"github.com/food-ai/backend/internal/cuisine"
|
||||||
"github.com/food-ai/backend/internal/diary"
|
"github.com/food-ai/backend/internal/diary"
|
||||||
|
"github.com/food-ai/backend/internal/dish"
|
||||||
"github.com/food-ai/backend/internal/home"
|
"github.com/food-ai/backend/internal/home"
|
||||||
"github.com/food-ai/backend/internal/ingredient"
|
"github.com/food-ai/backend/internal/ingredient"
|
||||||
"github.com/food-ai/backend/internal/language"
|
"github.com/food-ai/backend/internal/language"
|
||||||
"github.com/food-ai/backend/internal/menu"
|
"github.com/food-ai/backend/internal/menu"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
|
"github.com/food-ai/backend/internal/recipe"
|
||||||
|
"github.com/food-ai/backend/internal/tag"
|
||||||
"github.com/food-ai/backend/internal/units"
|
"github.com/food-ai/backend/internal/units"
|
||||||
"github.com/food-ai/backend/internal/product"
|
"github.com/food-ai/backend/internal/product"
|
||||||
"github.com/food-ai/backend/internal/recognition"
|
"github.com/food-ai/backend/internal/recognition"
|
||||||
@@ -33,6 +37,8 @@ func NewRouter(
|
|||||||
menuHandler *menu.Handler,
|
menuHandler *menu.Handler,
|
||||||
diaryHandler *diary.Handler,
|
diaryHandler *diary.Handler,
|
||||||
homeHandler *home.Handler,
|
homeHandler *home.Handler,
|
||||||
|
dishHandler *dish.Handler,
|
||||||
|
recipeHandler *recipe.Handler,
|
||||||
authMiddleware func(http.Handler) http.Handler,
|
authMiddleware func(http.Handler) http.Handler,
|
||||||
allowedOrigins []string,
|
allowedOrigins []string,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
@@ -49,6 +55,8 @@ func NewRouter(
|
|||||||
r.Get("/health", healthCheck(pool))
|
r.Get("/health", healthCheck(pool))
|
||||||
r.Get("/languages", language.List)
|
r.Get("/languages", language.List)
|
||||||
r.Get("/units", units.List)
|
r.Get("/units", units.List)
|
||||||
|
r.Get("/cuisines", cuisine.List)
|
||||||
|
r.Get("/tags", tag.List)
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Post("/login", authHandler.Login)
|
r.Post("/login", authHandler.Login)
|
||||||
r.Post("/refresh", authHandler.Refresh)
|
r.Post("/refresh", authHandler.Refresh)
|
||||||
@@ -81,6 +89,13 @@ func NewRouter(
|
|||||||
r.Delete("/{id}", productHandler.Delete)
|
r.Delete("/{id}", productHandler.Delete)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/dishes", func(r chi.Router) {
|
||||||
|
r.Get("/", dishHandler.List)
|
||||||
|
r.Get("/{id}", dishHandler.GetByID)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/recipes/{id}", recipeHandler.GetByID)
|
||||||
|
|
||||||
r.Route("/menu", func(r chi.Router) {
|
r.Route("/menu", func(r chi.Router) {
|
||||||
r.Get("/", menuHandler.GetMenu)
|
r.Get("/", menuHandler.GetMenu)
|
||||||
r.Put("/items/{id}", menuHandler.UpdateMenuItem)
|
r.Put("/items/{id}", menuHandler.UpdateMenuItem)
|
||||||
|
|||||||
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;
|
|
||||||
22
client/lib/core/api/cuisine_repository.dart
Normal file
22
client/lib/core/api/cuisine_repository.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
import '../../shared/models/cuisine.dart';
|
||||||
|
|
||||||
|
class CuisineRepository {
|
||||||
|
final ApiClient _api;
|
||||||
|
CuisineRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Cuisine>> fetchCuisines() async {
|
||||||
|
final data = await _api.get('/cuisines');
|
||||||
|
final List<dynamic> items = data['cuisines'] as List;
|
||||||
|
return items
|
||||||
|
.map((e) => Cuisine.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final cuisineRepositoryProvider = Provider<CuisineRepository>(
|
||||||
|
(ref) => CuisineRepository(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
22
client/lib/core/api/tag_repository.dart
Normal file
22
client/lib/core/api/tag_repository.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
import '../../shared/models/tag.dart';
|
||||||
|
|
||||||
|
class TagRepository {
|
||||||
|
final ApiClient _api;
|
||||||
|
TagRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<Tag>> fetchTags() async {
|
||||||
|
final data = await _api.get('/tags');
|
||||||
|
final List<dynamic> items = data['tags'] as List;
|
||||||
|
return items
|
||||||
|
.map((e) => Tag.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final tagRepositoryProvider = Provider<TagRepository>(
|
||||||
|
(ref) => TagRepository(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
19
client/lib/core/locale/cuisine_provider.dart
Normal file
19
client/lib/core/locale/cuisine_provider.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/cuisine_repository.dart';
|
||||||
|
import '../../shared/models/cuisine.dart';
|
||||||
|
import 'language_provider.dart';
|
||||||
|
|
||||||
|
/// Fetches and caches cuisines with localized names.
|
||||||
|
/// Returns list of [Cuisine] objects.
|
||||||
|
/// Re-fetches automatically when languageProvider changes.
|
||||||
|
final cuisinesProvider = FutureProvider<List<Cuisine>>((ref) {
|
||||||
|
ref.watch(languageProvider); // invalidate when language changes
|
||||||
|
return ref.read(cuisineRepositoryProvider).fetchCuisines();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convenience provider that returns a slug → localized name map.
|
||||||
|
final cuisineNamesProvider = FutureProvider<Map<String, String>>((ref) async {
|
||||||
|
final cuisines = await ref.watch(cuisinesProvider.future);
|
||||||
|
return {for (final c in cuisines) c.slug: c.name};
|
||||||
|
});
|
||||||
19
client/lib/core/locale/tag_provider.dart
Normal file
19
client/lib/core/locale/tag_provider.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/tag_repository.dart';
|
||||||
|
import '../../shared/models/tag.dart';
|
||||||
|
import 'language_provider.dart';
|
||||||
|
|
||||||
|
/// Fetches and caches tags with localized names.
|
||||||
|
/// Returns list of [Tag] objects.
|
||||||
|
/// Re-fetches automatically when languageProvider changes.
|
||||||
|
final tagsProvider = FutureProvider<List<Tag>>((ref) {
|
||||||
|
ref.watch(languageProvider); // invalidate when language changes
|
||||||
|
return ref.read(tagRepositoryProvider).fetchTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convenience provider that returns a slug → localized name map.
|
||||||
|
final tagNamesProvider = FutureProvider<Map<String, String>>((ref) async {
|
||||||
|
final tags = await ref.watch(tagsProvider.future);
|
||||||
|
return {for (final t in tags) t.slug: t.name};
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/locale/cuisine_provider.dart';
|
||||||
|
import '../../core/locale/tag_provider.dart';
|
||||||
import '../../core/locale/unit_provider.dart';
|
import '../../core/locale/unit_provider.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../shared/models/recipe.dart';
|
import '../../shared/models/recipe.dart';
|
||||||
@@ -200,7 +202,7 @@ class _PlaceholderImage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetaChips extends StatelessWidget {
|
class _MetaChips extends ConsumerWidget {
|
||||||
final int? prepTimeMin;
|
final int? prepTimeMin;
|
||||||
final int? cookTimeMin;
|
final int? cookTimeMin;
|
||||||
final String? difficulty;
|
final String? difficulty;
|
||||||
@@ -216,8 +218,9 @@ class _MetaChips extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
|
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
|
||||||
|
final cuisineNames = ref.watch(cuisineNamesProvider).valueOrNull ?? {};
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
@@ -227,7 +230,9 @@ class _MetaChips extends StatelessWidget {
|
|||||||
if (difficulty != null)
|
if (difficulty != null)
|
||||||
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
|
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
|
||||||
if (cuisine != null)
|
if (cuisine != null)
|
||||||
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)),
|
_Chip(
|
||||||
|
icon: Icons.public,
|
||||||
|
label: cuisineNames[cuisine!] ?? cuisine!),
|
||||||
if (servings != null)
|
if (servings != null)
|
||||||
_Chip(icon: Icons.people, label: '$servings порц.'),
|
_Chip(icon: Icons.people, label: '$servings порц.'),
|
||||||
],
|
],
|
||||||
@@ -240,15 +245,6 @@ class _MetaChips extends StatelessWidget {
|
|||||||
'hard' => 'Сложно',
|
'hard' => 'Сложно',
|
||||||
_ => d,
|
_ => d,
|
||||||
};
|
};
|
||||||
|
|
||||||
String _cuisineLabel(String c) => switch (c) {
|
|
||||||
'russian' => 'Русская',
|
|
||||||
'asian' => 'Азиатская',
|
|
||||||
'european' => 'Европейская',
|
|
||||||
'mediterranean' => 'Средиземноморская',
|
|
||||||
'american' => 'Американская',
|
|
||||||
_ => 'Другая',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Chip extends StatelessWidget {
|
class _Chip extends StatelessWidget {
|
||||||
@@ -347,20 +343,24 @@ class _NutCell extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TagsRow extends StatelessWidget {
|
class _TagsRow extends ConsumerWidget {
|
||||||
final List<String> tags;
|
final List<String> tags;
|
||||||
|
|
||||||
const _TagsRow({required this.tags});
|
const _TagsRow({required this.tags});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tagNames = ref.watch(tagNamesProvider).valueOrNull ?? {};
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: tags
|
children: tags
|
||||||
.map(
|
.map(
|
||||||
(t) => Chip(
|
(t) => Chip(
|
||||||
label: Text(t, style: const TextStyle(fontSize: 11)),
|
label: Text(
|
||||||
|
tagNames[t] ?? t,
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
backgroundColor: AppColors.primary.withValues(alpha: 0.15),
|
backgroundColor: AppColors.primary.withValues(alpha: 0.15),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
@@ -402,7 +402,7 @@ class _IngredientsSection extends ConsumerWidget {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: Text(ing.name)),
|
Expanded(child: Text(ing.name)),
|
||||||
Text(
|
Text(
|
||||||
'${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.unit] ?? ing.unit}',
|
'${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.effectiveUnit] ?? ing.effectiveUnit}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textSecondary, fontSize: 13),
|
color: AppColors.textSecondary, fontSize: 13),
|
||||||
),
|
),
|
||||||
|
|||||||
13
client/lib/shared/models/cuisine.dart
Normal file
13
client/lib/shared/models/cuisine.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class Cuisine {
|
||||||
|
final String slug;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const Cuisine({required this.slug, required this.name});
|
||||||
|
|
||||||
|
factory Cuisine.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Cuisine(
|
||||||
|
slug: json['slug'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ class DiaryEntry {
|
|||||||
final double? fatG;
|
final double? fatG;
|
||||||
final double? carbsG;
|
final double? carbsG;
|
||||||
final String source;
|
final String source;
|
||||||
|
final String? dishId;
|
||||||
final String? recipeId;
|
final String? recipeId;
|
||||||
|
final double? portionG;
|
||||||
|
|
||||||
const DiaryEntry({
|
const DiaryEntry({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -22,7 +24,9 @@ class DiaryEntry {
|
|||||||
this.fatG,
|
this.fatG,
|
||||||
this.carbsG,
|
this.carbsG,
|
||||||
required this.source,
|
required this.source,
|
||||||
|
this.dishId,
|
||||||
this.recipeId,
|
this.recipeId,
|
||||||
|
this.portionG,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DiaryEntry.fromJson(Map<String, dynamic> json) {
|
factory DiaryEntry.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -37,7 +41,9 @@ class DiaryEntry {
|
|||||||
fatG: (json['fat_g'] as num?)?.toDouble(),
|
fatG: (json['fat_g'] as num?)?.toDouble(),
|
||||||
carbsG: (json['carbs_g'] as num?)?.toDouble(),
|
carbsG: (json['carbs_g'] as num?)?.toDouble(),
|
||||||
source: json['source'] as String? ?? 'manual',
|
source: json['source'] as String? ?? 'manual',
|
||||||
|
dishId: json['dish_id'] as String?,
|
||||||
recipeId: json['recipe_id'] as String?,
|
recipeId: json['recipe_id'] as String?,
|
||||||
|
portionG: (json['portion_g'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,14 +59,25 @@ class Recipe {
|
|||||||
class RecipeIngredient {
|
class RecipeIngredient {
|
||||||
final String name;
|
final String name;
|
||||||
final double amount;
|
final double amount;
|
||||||
|
|
||||||
|
/// Unit from Gemini recommendations (free-form string).
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
final String unit;
|
final String unit;
|
||||||
|
|
||||||
|
/// Unit code from the DB-backed saved recipes endpoint.
|
||||||
|
@JsonKey(name: 'unit_code')
|
||||||
|
final String? unitCode;
|
||||||
|
|
||||||
const RecipeIngredient({
|
const RecipeIngredient({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.amount,
|
required this.amount,
|
||||||
required this.unit,
|
this.unit = '',
|
||||||
|
this.unitCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Returns the best available unit identifier for display / lookup.
|
||||||
|
String get effectiveUnit => unitCode ?? unit;
|
||||||
|
|
||||||
factory RecipeIngredient.fromJson(Map<String, dynamic> json) =>
|
factory RecipeIngredient.fromJson(Map<String, dynamic> json) =>
|
||||||
_$RecipeIngredientFromJson(json);
|
_$RecipeIngredientFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$RecipeIngredientToJson(this);
|
Map<String, dynamic> toJson() => _$RecipeIngredientToJson(this);
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ RecipeIngredient _$RecipeIngredientFromJson(Map<String, dynamic> json) =>
|
|||||||
RecipeIngredient(
|
RecipeIngredient(
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
amount: (json['amount'] as num).toDouble(),
|
amount: (json['amount'] as num).toDouble(),
|
||||||
unit: json['unit'] as String,
|
unit: json['unit'] as String? ?? '',
|
||||||
|
unitCode: json['unit_code'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
|
Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
|
||||||
@@ -63,6 +64,7 @@ Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
|
|||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'amount': instance.amount,
|
'amount': instance.amount,
|
||||||
'unit': instance.unit,
|
'unit': instance.unit,
|
||||||
|
'unit_code': instance.unitCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
|
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
|
||||||
|
|||||||
@@ -1,47 +1,36 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
import 'recipe.dart';
|
import 'recipe.dart';
|
||||||
|
|
||||||
part 'saved_recipe.g.dart';
|
/// A user's bookmarked recipe. Display fields are joined from dishes + recipes.
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
|
||||||
class SavedRecipe {
|
class SavedRecipe {
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
final String recipeId;
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String? description;
|
final String? description;
|
||||||
|
|
||||||
|
/// Mapped from cuisine_slug in the API response.
|
||||||
final String? cuisine;
|
final String? cuisine;
|
||||||
|
|
||||||
final String? difficulty;
|
final String? difficulty;
|
||||||
|
|
||||||
@JsonKey(name: 'prep_time_min')
|
|
||||||
final int? prepTimeMin;
|
final int? prepTimeMin;
|
||||||
|
|
||||||
@JsonKey(name: 'cook_time_min')
|
|
||||||
final int? cookTimeMin;
|
final int? cookTimeMin;
|
||||||
|
|
||||||
final int? servings;
|
final int? servings;
|
||||||
|
|
||||||
@JsonKey(name: 'image_url')
|
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
|
|
||||||
@JsonKey(defaultValue: [])
|
|
||||||
final List<RecipeIngredient> ingredients;
|
final List<RecipeIngredient> ingredients;
|
||||||
|
|
||||||
@JsonKey(defaultValue: [])
|
|
||||||
final List<RecipeStep> steps;
|
final List<RecipeStep> steps;
|
||||||
|
|
||||||
@JsonKey(defaultValue: [])
|
|
||||||
final List<String> tags;
|
final List<String> tags;
|
||||||
|
|
||||||
@JsonKey(name: 'nutrition_per_serving')
|
|
||||||
final NutritionInfo? nutrition;
|
|
||||||
|
|
||||||
final String source;
|
|
||||||
|
|
||||||
@JsonKey(name: 'saved_at')
|
|
||||||
final DateTime savedAt;
|
final DateTime savedAt;
|
||||||
|
|
||||||
|
// Individual nutrition columns (nullable).
|
||||||
|
final double? caloriesPerServing;
|
||||||
|
final double? proteinPerServing;
|
||||||
|
final double? fatPerServing;
|
||||||
|
final double? carbsPerServing;
|
||||||
|
|
||||||
const SavedRecipe({
|
const SavedRecipe({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.recipeId,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.description,
|
this.description,
|
||||||
this.cuisine,
|
this.cuisine,
|
||||||
@@ -53,12 +42,81 @@ class SavedRecipe {
|
|||||||
this.ingredients = const [],
|
this.ingredients = const [],
|
||||||
this.steps = const [],
|
this.steps = const [],
|
||||||
this.tags = const [],
|
this.tags = const [],
|
||||||
this.nutrition,
|
|
||||||
required this.source,
|
|
||||||
required this.savedAt,
|
required this.savedAt,
|
||||||
|
this.caloriesPerServing,
|
||||||
|
this.proteinPerServing,
|
||||||
|
this.fatPerServing,
|
||||||
|
this.carbsPerServing,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SavedRecipe.fromJson(Map<String, dynamic> json) =>
|
/// Builds a [NutritionInfo] from the individual per-serving columns,
|
||||||
_$SavedRecipeFromJson(json);
|
/// or null when no calorie data is available.
|
||||||
Map<String, dynamic> toJson() => _$SavedRecipeToJson(this);
|
NutritionInfo? get nutrition {
|
||||||
|
if (caloriesPerServing == null) return null;
|
||||||
|
return NutritionInfo(
|
||||||
|
calories: caloriesPerServing!,
|
||||||
|
proteinG: proteinPerServing ?? 0,
|
||||||
|
fatG: fatPerServing ?? 0,
|
||||||
|
carbsG: carbsPerServing ?? 0,
|
||||||
|
approximate: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SavedRecipe.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SavedRecipe(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
recipeId: json['recipe_id'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
cuisine: json['cuisine_slug'] as String?,
|
||||||
|
difficulty: json['difficulty'] as String?,
|
||||||
|
prepTimeMin: (json['prep_time_min'] as num?)?.toInt(),
|
||||||
|
cookTimeMin: (json['cook_time_min'] as num?)?.toInt(),
|
||||||
|
servings: (json['servings'] as num?)?.toInt(),
|
||||||
|
imageUrl: json['image_url'] as String?,
|
||||||
|
ingredients: (json['ingredients'] as List<dynamic>?)
|
||||||
|
?.map((e) =>
|
||||||
|
RecipeIngredient.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
steps: (json['steps'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => RecipeStep.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
tags: (json['tags'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
savedAt: DateTime.tryParse(json['saved_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
caloriesPerServing:
|
||||||
|
(json['calories_per_serving'] as num?)?.toDouble(),
|
||||||
|
proteinPerServing:
|
||||||
|
(json['protein_per_serving'] as num?)?.toDouble(),
|
||||||
|
fatPerServing: (json['fat_per_serving'] as num?)?.toDouble(),
|
||||||
|
carbsPerServing: (json['carbs_per_serving'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'recipe_id': recipeId,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'cuisine_slug': cuisine,
|
||||||
|
'difficulty': difficulty,
|
||||||
|
'prep_time_min': prepTimeMin,
|
||||||
|
'cook_time_min': cookTimeMin,
|
||||||
|
'servings': servings,
|
||||||
|
'image_url': imageUrl,
|
||||||
|
'ingredients': ingredients.map((e) => e.toJson()).toList(),
|
||||||
|
'steps': steps.map((e) => e.toJson()).toList(),
|
||||||
|
'tags': tags,
|
||||||
|
'saved_at': savedAt.toIso8601String(),
|
||||||
|
'calories_per_serving': caloriesPerServing,
|
||||||
|
'protein_per_serving': proteinPerServing,
|
||||||
|
'fat_per_serving': fatPerServing,
|
||||||
|
'carbs_per_serving': carbsPerServing,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,3 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// This file is intentionally empty.
|
||||||
part of 'saved_recipe.dart';
|
// saved_recipe.dart now uses a manually written fromJson/toJson.
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
SavedRecipe _$SavedRecipeFromJson(Map<String, dynamic> json) => SavedRecipe(
|
|
||||||
id: json['id'] as String,
|
|
||||||
title: json['title'] as String,
|
|
||||||
description: json['description'] as String?,
|
|
||||||
cuisine: json['cuisine'] as String?,
|
|
||||||
difficulty: json['difficulty'] as String?,
|
|
||||||
prepTimeMin: (json['prep_time_min'] as num?)?.toInt(),
|
|
||||||
cookTimeMin: (json['cook_time_min'] as num?)?.toInt(),
|
|
||||||
servings: (json['servings'] as num?)?.toInt(),
|
|
||||||
imageUrl: json['image_url'] as String?,
|
|
||||||
ingredients:
|
|
||||||
(json['ingredients'] as List<dynamic>?)
|
|
||||||
?.map((e) => RecipeIngredient.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
[],
|
|
||||||
steps:
|
|
||||||
(json['steps'] as List<dynamic>?)
|
|
||||||
?.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
[],
|
|
||||||
tags:
|
|
||||||
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
|
||||||
nutrition: json['nutrition_per_serving'] == null
|
|
||||||
? null
|
|
||||||
: NutritionInfo.fromJson(
|
|
||||||
json['nutrition_per_serving'] as Map<String, dynamic>,
|
|
||||||
),
|
|
||||||
source: json['source'] as String,
|
|
||||||
savedAt: DateTime.parse(json['saved_at'] as String),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$SavedRecipeToJson(SavedRecipe instance) =>
|
|
||||||
<String, dynamic>{
|
|
||||||
'id': instance.id,
|
|
||||||
'title': instance.title,
|
|
||||||
'description': instance.description,
|
|
||||||
'cuisine': instance.cuisine,
|
|
||||||
'difficulty': instance.difficulty,
|
|
||||||
'prep_time_min': instance.prepTimeMin,
|
|
||||||
'cook_time_min': instance.cookTimeMin,
|
|
||||||
'servings': instance.servings,
|
|
||||||
'image_url': instance.imageUrl,
|
|
||||||
'ingredients': instance.ingredients.map((e) => e.toJson()).toList(),
|
|
||||||
'steps': instance.steps.map((e) => e.toJson()).toList(),
|
|
||||||
'tags': instance.tags,
|
|
||||||
'nutrition_per_serving': instance.nutrition?.toJson(),
|
|
||||||
'source': instance.source,
|
|
||||||
'saved_at': instance.savedAt.toIso8601String(),
|
|
||||||
};
|
|
||||||
|
|||||||
13
client/lib/shared/models/tag.dart
Normal file
13
client/lib/shared/models/tag.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class Tag {
|
||||||
|
final String slug;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const Tag({required this.slug, required this.name});
|
||||||
|
|
||||||
|
factory Tag.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Tag(
|
||||||
|
slug: json['slug'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user