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

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

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

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

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

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

View File

@@ -0,0 +1,28 @@
package cuisine
import (
"encoding/json"
"net/http"
"github.com/food-ai/backend/internal/locale"
)
type cuisineItem struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
// List handles GET /cuisines — returns cuisines with names in the requested language.
func List(w http.ResponseWriter, r *http.Request) {
lang := locale.FromContext(r.Context())
items := make([]cuisineItem, 0, len(Records))
for _, c := range Records {
name, ok := c.Translations[lang]
if !ok {
name = c.Name
}
items = append(items, cuisineItem{Slug: c.Slug, Name: name})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"cuisines": items})
}

View File

@@ -0,0 +1,80 @@
package cuisine
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Record is a cuisine loaded from DB with all its translations.
type Record struct {
Slug string
Name string // English canonical name
SortOrder int
// Translations maps lang code to localized name.
Translations map[string]string
}
// Records is the ordered list of cuisines, populated by LoadFromDB at startup.
var Records []Record
// LoadFromDB queries cuisines + cuisine_translations and populates Records.
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
rows, err := pool.Query(ctx, `
SELECT c.slug, c.name, c.sort_order, ct.lang, ct.name
FROM cuisines c
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug
ORDER BY c.sort_order, ct.lang`)
if err != nil {
return fmt.Errorf("load cuisines from db: %w", err)
}
defer rows.Close()
bySlug := map[string]*Record{}
var order []string
for rows.Next() {
var slug, engName string
var sortOrder int
var lang, name *string
if err := rows.Scan(&slug, &engName, &sortOrder, &lang, &name); err != nil {
return err
}
if _, ok := bySlug[slug]; !ok {
bySlug[slug] = &Record{
Slug: slug,
Name: engName,
SortOrder: sortOrder,
Translations: map[string]string{},
}
order = append(order, slug)
}
if lang != nil && name != nil {
bySlug[slug].Translations[*lang] = *name
}
}
if err := rows.Err(); err != nil {
return err
}
result := make([]Record, 0, len(order))
for _, slug := range order {
result = append(result, *bySlug[slug])
}
Records = result
return nil
}
// NameFor returns the localized name for a cuisine slug.
// Falls back to the English canonical name.
func NameFor(slug, lang string) string {
for _, c := range Records {
if c.Slug == slug {
if name, ok := c.Translations[lang]; ok {
return name
}
return c.Name
}
}
return slug
}

View File

@@ -14,20 +14,24 @@ type Entry struct {
FatG *float64 `json:"fat_g,omitempty"`
CarbsG *float64 `json:"carbs_g,omitempty"`
Source string `json:"source"`
DishID *string `json:"dish_id,omitempty"`
RecipeID *string `json:"recipe_id,omitempty"`
PortionG *float64 `json:"portion_g,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CreateRequest is the body for POST /diary.
type CreateRequest struct {
Date string `json:"date"`
MealType string `json:"meal_type"`
Name string `json:"name"`
Portions float64 `json:"portions"`
Date string `json:"date"`
MealType string `json:"meal_type"`
Name string `json:"name"`
Portions float64 `json:"portions"`
Calories *float64 `json:"calories"`
ProteinG *float64 `json:"protein_g"`
FatG *float64 `json:"fat_g"`
CarbsG *float64 `json:"carbs_g"`
Source string `json:"source"`
RecipeID *string `json:"recipe_id"`
Source string `json:"source"`
DishID *string `json:"dish_id"`
RecipeID *string `json:"recipe_id"`
PortionG *float64 `json:"portion_g"`
}

View File

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

View File

@@ -0,0 +1,67 @@
package dish
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
)
// Handler handles HTTP requests for dishes.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// List handles GET /dishes — returns all dishes (no recipe variants).
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
dishes, err := h.repo.List(r.Context())
if err != nil {
slog.Error("list dishes", "err", err)
writeError(w, http.StatusInternalServerError, "failed to list dishes")
return
}
if dishes == nil {
dishes = []*Dish{}
}
writeJSON(w, http.StatusOK, map[string]any{"dishes": dishes})
}
// GetByID handles GET /dishes/{id} — returns a dish with all recipe variants.
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dish, err := h.repo.GetByID(r.Context(), id)
if err != nil {
slog.Error("get dish", "id", id, "err", err)
writeError(w, http.StatusInternalServerError, "failed to get dish")
return
}
if dish == nil {
writeError(w, http.StatusNotFound, "dish not found")
return
}
writeJSON(w, http.StatusOK, dish)
}
// --- helpers ---
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,96 @@
package dish
import "time"
// Dish is a canonical dish record combining dish metadata with optional recipes.
type Dish struct {
ID string `json:"id"`
CuisineSlug *string `json:"cuisine_slug"`
CategorySlug *string `json:"category_slug"`
Name string `json:"name"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
Tags []string `json:"tags"`
AvgRating float64 `json:"avg_rating"`
ReviewCount int `json:"review_count"`
Recipes []Recipe `json:"recipes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Recipe is a cooking variant attached to a Dish.
type Recipe struct {
ID string `json:"id"`
DishID string `json:"dish_id"`
Source string `json:"source"`
Difficulty *string `json:"difficulty"`
PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"`
CaloriesPerServing *float64 `json:"calories_per_serving"`
ProteinPerServing *float64 `json:"protein_per_serving"`
FatPerServing *float64 `json:"fat_per_serving"`
CarbsPerServing *float64 `json:"carbs_per_serving"`
FiberPerServing *float64 `json:"fiber_per_serving"`
Ingredients []RecipeIngredient `json:"ingredients"`
Steps []RecipeStep `json:"steps"`
Notes *string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RecipeIngredient is a single ingredient row from recipe_ingredients.
type RecipeIngredient struct {
ID string `json:"id"`
IngredientID *string `json:"ingredient_id"`
Name string `json:"name"`
Amount float64 `json:"amount"`
UnitCode *string `json:"unit_code"`
IsOptional bool `json:"is_optional"`
SortOrder int `json:"sort_order"`
}
// RecipeStep is a single step row from recipe_steps.
type RecipeStep struct {
ID string `json:"id"`
StepNumber int `json:"step_number"`
Description string `json:"description"`
TimerSeconds *int `json:"timer_seconds"`
ImageURL *string `json:"image_url"`
}
// CreateRequest is the body used to create a new dish + recipe at once.
// Used when saving a Gemini-generated recommendation.
type CreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
CuisineSlug string `json:"cuisine_slug"`
ImageURL string `json:"image_url"`
Tags []string `json:"tags"`
Source string `json:"source"`
Difficulty string `json:"difficulty"`
PrepTimeMin int `json:"prep_time_min"`
CookTimeMin int `json:"cook_time_min"`
Servings int `json:"servings"`
Calories float64 `json:"calories_per_serving"`
Protein float64 `json:"protein_per_serving"`
Fat float64 `json:"fat_per_serving"`
Carbs float64 `json:"carbs_per_serving"`
Ingredients []IngredientInput `json:"ingredients"`
Steps []StepInput `json:"steps"`
}
// IngredientInput is a single ingredient in the create request.
type IngredientInput struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
IsOptional bool `json:"is_optional"`
}
// StepInput is a single step in the create request.
type StepInput struct {
Number int `json:"number"`
Description string `json:"description"`
TimerSeconds *int `json:"timer_seconds"`
}

View File

@@ -0,0 +1,370 @@
package dish
import (
"context"
"errors"
"fmt"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for dishes and their recipes.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// GetByID returns a dish with all its tag slugs and recipe variants.
// Text is resolved for the language in ctx (English fallback).
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Dish, error) {
lang := locale.FromContext(ctx)
const q = `
SELECT d.id,
d.cuisine_slug, d.category_slug,
COALESCE(dt.name, d.name) AS name,
COALESCE(dt.description, d.description) AS description,
d.image_url, d.avg_rating, d.review_count,
d.created_at, d.updated_at
FROM dishes d
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
WHERE d.id = $1`
row := r.pool.QueryRow(ctx, q, id, lang)
dish, err := scanDish(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get dish %s: %w", id, err)
}
if err := r.loadTags(ctx, dish); err != nil {
return nil, err
}
if err := r.loadRecipes(ctx, dish, lang); err != nil {
return nil, err
}
return dish, nil
}
// List returns all dishes with tag slugs (no recipe variants).
// Text is resolved for the language in ctx.
func (r *Repository) List(ctx context.Context) ([]*Dish, error) {
lang := locale.FromContext(ctx)
const q = `
SELECT d.id,
d.cuisine_slug, d.category_slug,
COALESCE(dt.name, d.name) AS name,
COALESCE(dt.description, d.description) AS description,
d.image_url, d.avg_rating, d.review_count,
d.created_at, d.updated_at
FROM dishes d
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $1
ORDER BY d.updated_at DESC`
rows, err := r.pool.Query(ctx, q, lang)
if err != nil {
return nil, fmt.Errorf("list dishes: %w", err)
}
defer rows.Close()
var dishes []*Dish
for rows.Next() {
d, err := scanDish(rows)
if err != nil {
return nil, fmt.Errorf("scan dish: %w", err)
}
dishes = append(dishes, d)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load tags for each dish.
for _, d := range dishes {
if err := r.loadTags(ctx, d); err != nil {
return nil, err
}
}
return dishes, nil
}
// Create creates a new dish + recipe row from a CreateRequest.
// Returns the recipe ID to be used in menu_items or user_saved_recipes.
func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
// Resolve cuisine slug (empty string → NULL).
cuisineSlug := nullableStr(req.CuisineSlug)
// Insert dish.
var dishID string
err = tx.QueryRow(ctx, `
INSERT INTO dishes (cuisine_slug, name, description, image_url)
VALUES ($1, $2, NULLIF($3,''), NULLIF($4,''))
RETURNING id`,
cuisineSlug, req.Name, req.Description, req.ImageURL,
).Scan(&dishID)
if err != nil {
return "", fmt.Errorf("insert dish: %w", err)
}
// Insert tags.
for _, slug := range req.Tags {
if _, err := tx.Exec(ctx,
`INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
dishID, slug,
); err != nil {
return "", fmt.Errorf("insert dish tag %s: %w", slug, err)
}
}
// Insert recipe.
source := req.Source
if source == "" {
source = "ai"
}
difficulty := nullableStr(req.Difficulty)
prepTime := nullableInt(req.PrepTimeMin)
cookTime := nullableInt(req.CookTimeMin)
servings := nullableInt(req.Servings)
calories := nullableFloat(req.Calories)
protein := nullableFloat(req.Protein)
fat := nullableFloat(req.Fat)
carbs := nullableFloat(req.Carbs)
err = tx.QueryRow(ctx, `
INSERT INTO recipes (dish_id, source, difficulty, prep_time_min, cook_time_min, servings,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
dishID, source, difficulty, prepTime, cookTime, servings,
calories, protein, fat, carbs,
).Scan(&recipeID)
if err != nil {
return "", fmt.Errorf("insert recipe: %w", err)
}
// Insert recipe_ingredients.
for i, ing := range req.Ingredients {
if _, err := tx.Exec(ctx, `
INSERT INTO recipe_ingredients (recipe_id, name, amount, unit_code, is_optional, sort_order)
VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`,
recipeID, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i,
); err != nil {
return "", fmt.Errorf("insert ingredient %d: %w", i, err)
}
}
// Insert recipe_steps.
for _, s := range req.Steps {
num := s.Number
if num <= 0 {
num = 1
}
if _, err := tx.Exec(ctx, `
INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description)
VALUES ($1, $2, $3, $4)`,
recipeID, num, s.TimerSeconds, s.Description,
); err != nil {
return "", fmt.Errorf("insert step %d: %w", num, err)
}
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("commit: %w", err)
}
return recipeID, nil
}
// loadTags fills d.Tags with the slugs from dish_tags.
func (r *Repository) loadTags(ctx context.Context, d *Dish) error {
rows, err := r.pool.Query(ctx,
`SELECT tag_slug FROM dish_tags WHERE dish_id = $1 ORDER BY tag_slug`, d.ID)
if err != nil {
return fmt.Errorf("load tags for dish %s: %w", d.ID, err)
}
defer rows.Close()
for rows.Next() {
var slug string
if err := rows.Scan(&slug); err != nil {
return err
}
d.Tags = append(d.Tags, slug)
}
return rows.Err()
}
// loadRecipes fills d.Recipes with all recipe variants for the dish,
// including their relational ingredients and steps.
func (r *Repository) loadRecipes(ctx context.Context, d *Dish, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT r.id, r.dish_id, r.source, r.difficulty,
r.prep_time_min, r.cook_time_min, r.servings,
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving,
r.carbs_per_serving, r.fiber_per_serving,
rt.notes,
r.created_at, r.updated_at
FROM recipes r
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
WHERE r.dish_id = $1
ORDER BY r.created_at DESC`, d.ID, lang)
if err != nil {
return fmt.Errorf("load recipes for dish %s: %w", d.ID, err)
}
defer rows.Close()
for rows.Next() {
rec, err := scanRecipe(rows)
if err != nil {
return fmt.Errorf("scan recipe: %w", err)
}
d.Recipes = append(d.Recipes, *rec)
}
if err := rows.Err(); err != nil {
return err
}
// Load ingredients and steps for each recipe.
for i := range d.Recipes {
if err := r.loadIngredients(ctx, &d.Recipes[i], lang); err != nil {
return err
}
if err := r.loadSteps(ctx, &d.Recipes[i], lang); err != nil {
return err
}
}
return nil
}
// loadIngredients fills rec.Ingredients from recipe_ingredients.
func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT ri.id, ri.ingredient_id,
COALESCE(rit.name, ri.name) AS name,
ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
FROM recipe_ingredients ri
LEFT JOIN recipe_ingredient_translations rit
ON rit.ri_id = ri.id AND rit.lang = $2
WHERE ri.recipe_id = $1
ORDER BY ri.sort_order`, rec.ID, lang)
if err != nil {
return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err)
}
defer rows.Close()
for rows.Next() {
var ing RecipeIngredient
if err := rows.Scan(
&ing.ID, &ing.IngredientID, &ing.Name,
&ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder,
); err != nil {
return fmt.Errorf("scan ingredient: %w", err)
}
rec.Ingredients = append(rec.Ingredients, ing)
}
return rows.Err()
}
// loadSteps fills rec.Steps from recipe_steps.
func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT rs.id, rs.step_number,
COALESCE(rst.description, rs.description) AS description,
rs.timer_seconds, rs.image_url
FROM recipe_steps rs
LEFT JOIN recipe_step_translations rst
ON rst.step_id = rs.id AND rst.lang = $2
WHERE rs.recipe_id = $1
ORDER BY rs.step_number`, rec.ID, lang)
if err != nil {
return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err)
}
defer rows.Close()
for rows.Next() {
var s RecipeStep
if err := rows.Scan(
&s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL,
); err != nil {
return fmt.Errorf("scan step: %w", err)
}
rec.Steps = append(rec.Steps, s)
}
return rows.Err()
}
// --- scan helpers ---
type scannable interface {
Scan(dest ...any) error
}
func scanDish(s scannable) (*Dish, error) {
var d Dish
err := s.Scan(
&d.ID, &d.CuisineSlug, &d.CategorySlug,
&d.Name, &d.Description, &d.ImageURL,
&d.AvgRating, &d.ReviewCount,
&d.CreatedAt, &d.UpdatedAt,
)
if err != nil {
return nil, err
}
d.Tags = []string{}
return &d, nil
}
func scanRecipe(s scannable) (*Recipe, error) {
var rec Recipe
err := s.Scan(
&rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
&rec.CarbsPerServing, &rec.FiberPerServing,
&rec.Notes,
&rec.CreatedAt, &rec.UpdatedAt,
)
if err != nil {
return nil, err
}
rec.Ingredients = []RecipeIngredient{}
rec.Steps = []RecipeStep{}
return &rec, nil
}
// --- null helpers ---
func nullableStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func nullableInt(n int) *int {
if n <= 0 {
return nil
}
return &n
}
func nullableFloat(f float64) *float64 {
if f <= 0 {
return nil
}
return &f
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for ingredient_mappings and their translations.
// Repository handles persistence for ingredients and their translations.
type Repository struct {
pool *pgxpool.Pool
}
@@ -21,11 +21,11 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates an ingredient mapping (English canonical content).
// Upsert inserts or updates an ingredient (English canonical content).
// Conflict is resolved on canonical_name.
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
query := `
INSERT INTO ingredient_mappings (
INSERT INTO ingredients (
canonical_name,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
@@ -54,7 +54,7 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
return scanMappingWrite(row)
}
// GetByID returns an ingredient mapping by UUID.
// GetByID returns an ingredient by UUID.
// CanonicalName and aliases are resolved for the language stored in ctx.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
@@ -68,7 +68,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
im.storage_days, im.created_at, im.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM ingredient_mappings im
FROM ingredients im
LEFT JOIN ingredient_translations it
ON it.ingredient_id = im.id AND it.lang = $2
LEFT JOIN ingredient_category_translations ict
@@ -88,7 +88,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
return m, err
}
// FuzzyMatch finds the single best matching ingredient mapping for a given name.
// FuzzyMatch finds the single best matching ingredient for a given name.
// Searches both English and translated names for the language in ctx.
// Returns nil, nil when no match is found.
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
@@ -102,7 +102,7 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa
return results[0], nil
}
// Search finds ingredient mappings matching the query string.
// Search finds ingredients matching the query string.
// Searches aliases table and translated names for the language in ctx.
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
if limit <= 0 {
@@ -118,7 +118,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
im.storage_days, im.created_at, im.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM ingredient_mappings im
FROM ingredients im
LEFT JOIN ingredient_translations it
ON it.ingredient_id = im.id AND it.lang = $3
LEFT JOIN ingredient_category_translations ict
@@ -142,17 +142,17 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In
rows, err := r.pool.Query(ctx, q, query, limit, lang)
if err != nil {
return nil, fmt.Errorf("search ingredient_mappings: %w", err)
return nil, fmt.Errorf("search ingredients: %w", err)
}
defer rows.Close()
return collectMappingsRead(rows)
}
// Count returns the total number of ingredient mappings.
// Count returns the total number of ingredients.
func (r *Repository) Count(ctx context.Context) (int, error) {
var n int
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil {
return 0, fmt.Errorf("count ingredient_mappings: %w", err)
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil {
return 0, fmt.Errorf("count ingredients: %w", err)
}
return n, nil
}
@@ -165,7 +165,7 @@ func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, li
im.category, im.default_unit,
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
im.storage_days, im.created_at, im.updated_at
FROM ingredient_mappings im
FROM ingredients im
WHERE NOT EXISTS (
SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = im.id AND it.lang = $3

View File

@@ -10,10 +10,10 @@ import (
"sync"
"time"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
"github.com/go-chi/chi/v5"
)
@@ -33,9 +33,9 @@ type ProductLister interface {
ListForPrompt(ctx context.Context, userID string) ([]string, error)
}
// RecipeSaver persists a single recipe and returns the stored record.
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
type RecipeSaver interface {
Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error)
Create(ctx context.Context, req dish.CreateRequest) (string, error)
}
// Handler handles menu and shopping-list endpoints.
@@ -136,7 +136,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
menuReq.AvailableProducts = products
}
// Generate 7-day plan via OpenAI.
// Generate 7-day plan via Gemini.
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
if err != nil {
slog.Error("generate menu", "user_id", userID, "err", err)
@@ -175,7 +175,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
}
// Save all 21 recipes to saved_recipes.
// Persist all 21 recipes as dish+recipe rows.
type savedRef struct {
day int
meal int
@@ -184,13 +184,13 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
refs := make([]savedRef, 0, len(days)*3)
for di, day := range days {
for mi, meal := range day.Meals {
saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe))
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
if err != nil {
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
writeError(w, http.StatusInternalServerError, "failed to save recipes")
return
}
refs = append(refs, savedRef{di, mi, saved.ID})
refs = append(refs, savedRef{di, mi, recipeID})
}
}
@@ -420,37 +420,25 @@ func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]Shopp
type key struct{ name, unit string }
totals := map[key]float64{}
categories := map[string]string{} // name → category (from meal_type heuristic)
for _, row := range rows {
var ingredients []struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
}
if len(row.IngredientsJSON) > 0 {
if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil {
continue
}
}
for _, ing := range ingredients {
k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit}
totals[k] += ing.Amount
if _, ok := categories[k.name]; !ok {
categories[k.name] = "other"
}
unit := ""
if row.UnitCode != nil {
unit = *row.UnitCode
}
k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit}
totals[k] += row.Amount
}
items := make([]ShoppingItem, 0, len(totals))
for k, amount := range totals {
items = append(items, ShoppingItem{
Name: k.name,
Category: categories[k.name],
Amount: amount,
Unit: k.unit,
Checked: false,
InStock: 0,
Name: k.name,
Category: "other",
Amount: amount,
Unit: k.unit,
Checked: false,
InStock: 0,
})
}
return items, nil
@@ -479,26 +467,70 @@ func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
return req
}
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest {
ingJSON, _ := json.Marshal(r.Ingredients)
stepsJSON, _ := json.Marshal(r.Steps)
tagsJSON, _ := json.Marshal(r.Tags)
nutritionJSON, _ := json.Marshal(r.Nutrition)
return savedrecipe.SaveRequest{
Title: r.Title,
// recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
func recipeToCreateRequest(r gemini.Recipe) dish.CreateRequest {
cr := dish.CreateRequest{
Name: r.Title,
Description: r.Description,
Cuisine: r.Cuisine,
CuisineSlug: mapCuisineSlug(r.Cuisine),
ImageURL: r.ImageURL,
Difficulty: r.Difficulty,
PrepTimeMin: r.PrepTimeMin,
CookTimeMin: r.CookTimeMin,
Servings: r.Servings,
ImageURL: r.ImageURL,
Ingredients: ingJSON,
Steps: stepsJSON,
Tags: tagsJSON,
Nutrition: nutritionJSON,
Calories: r.Nutrition.Calories,
Protein: r.Nutrition.ProteinG,
Fat: r.Nutrition.FatG,
Carbs: r.Nutrition.CarbsG,
Source: "menu",
}
for _, ing := range r.Ingredients {
cr.Ingredients = append(cr.Ingredients, dish.IngredientInput{
Name: ing.Name,
Amount: ing.Amount,
Unit: ing.Unit,
})
}
for _, s := range r.Steps {
cr.Steps = append(cr.Steps, dish.StepInput{
Number: s.Number,
Description: s.Description,
TimerSeconds: s.TimerSeconds,
})
}
cr.Tags = append(cr.Tags, r.Tags...)
return cr
}
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
// Falls back to "other".
func mapCuisineSlug(cuisine string) string {
known := map[string]string{
"russian": "russian",
"italian": "italian",
"french": "french",
"chinese": "chinese",
"japanese": "japanese",
"korean": "korean",
"mexican": "mexican",
"mediterranean": "mediterranean",
"indian": "indian",
"thai": "thai",
"american": "american",
"georgian": "georgian",
"spanish": "spanish",
"german": "german",
"middle_eastern": "middle_eastern",
"turkish": "turkish",
"greek": "greek",
"vietnamese": "vietnamese",
"asian": "other",
"european": "other",
}
if s, ok := known[cuisine]; ok {
return s
}
return "other"
}
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"time"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,18 +28,28 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
// Returns nil, nil when no plan exists for that week.
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
lang := locale.FromContext(ctx)
const q = `
SELECT mp.id, mp.week_start::text,
mi.id, mi.day_of_week, mi.meal_type,
sr.id, sr.title, COALESCE(sr.image_url, ''), sr.nutrition
rec.id,
COALESCE(dt.name, d.name),
COALESCE(d.image_url, ''),
rec.calories_per_serving,
rec.protein_per_serving,
rec.fat_per_serving,
rec.carbs_per_serving
FROM menu_plans mp
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
LEFT JOIN recipes rec ON rec.id = mi.recipe_id
LEFT JOIN dishes d ON d.id = rec.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
WHERE mp.user_id = $1 AND mp.week_start::text = $2
ORDER BY mi.day_of_week,
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
rows, err := r.pool.Query(ctx, q, userID, weekStart)
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
if err != nil {
return nil, fmt.Errorf("get menu by week: %w", err)
}
@@ -49,16 +60,17 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
for rows.Next() {
var (
planID, planWeekStart string
itemID, mealType *string
dow *int
recipeID, title, imageURL *string
nutritionRaw []byte
planID, planWeekStart string
itemID, mealType *string
dow *int
recipeID, title, imageURL *string
calPer, protPer, fatPer, carbPer *float64
)
if err := rows.Scan(
&planID, &planWeekStart,
&itemID, &dow, &mealType,
&recipeID, &title, &imageURL, &nutritionRaw,
&recipeID, &title, &imageURL,
&calPer, &protPer, &fatPer, &carbPer,
); err != nil {
return nil, fmt.Errorf("scan menu row: %w", err)
}
@@ -79,9 +91,11 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
slot := MealSlot{ID: *itemID, MealType: *mealType}
if recipeID != nil && title != nil {
var nutrition NutritionInfo
if len(nutritionRaw) > 0 {
_ = json.Unmarshal(nutritionRaw, &nutrition)
nutrition := NutritionInfo{
Calories: derefFloat(calPer),
ProteinG: derefFloat(protPer),
FatG: derefFloat(fatPer),
CarbsG: derefFloat(carbPer),
}
slot.Recipe = &MenuRecipe{
ID: *recipeID,
@@ -257,10 +271,12 @@ func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart stri
// GetIngredientsByPlan returns all ingredients from all recipes in the plan.
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
rows, err := r.pool.Query(ctx, `
SELECT sr.ingredients, sr.nutrition, mi.meal_type
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type
FROM menu_items mi
JOIN saved_recipes sr ON sr.id = mi.recipe_id
WHERE mi.menu_plan_id = $1`, planID)
JOIN recipes rec ON rec.id = mi.recipe_id
JOIN recipe_ingredients ri ON ri.recipe_id = rec.id
WHERE mi.menu_plan_id = $1
ORDER BY ri.sort_order`, planID)
if err != nil {
return nil, fmt.Errorf("get ingredients by plan: %w", err)
}
@@ -268,24 +284,20 @@ func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([
var result []ingredientRow
for rows.Next() {
var ingredientsRaw, nutritionRaw []byte
var mealType string
if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil {
var row ingredientRow
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
return nil, err
}
result = append(result, ingredientRow{
IngredientsJSON: ingredientsRaw,
NutritionJSON: nutritionRaw,
MealType: mealType,
})
result = append(result, row)
}
return result, rows.Err()
}
type ingredientRow struct {
IngredientsJSON []byte
NutritionJSON []byte
MealType string
Name string
Amount float64
UnitCode *string
MealType string
}
// --- helpers ---
@@ -304,3 +316,10 @@ func derefStr(s *string) string {
}
return *s
}
func derefFloat(f *float64) float64 {
if f == nil {
return 0
}
return *f
}

View File

@@ -4,28 +4,30 @@ import "time"
// Product is a user's food item in their pantry.
type Product struct {
ID string `json:"id"`
UserID string `json:"user_id"`
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
AddedAt time.Time `json:"added_at"`
ExpiresAt time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
ExpiringSoon bool `json:"expiring_soon"`
ID string `json:"id"`
UserID string `json:"user_id"`
PrimaryIngredientID *string `json:"primary_ingredient_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
AddedAt time.Time `json:"added_at"`
ExpiresAt time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
ExpiringSoon bool `json:"expiring_soon"`
}
// CreateRequest is the body for POST /products.
type CreateRequest struct {
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
PrimaryIngredientID *string `json:"primary_ingredient_id"`
// Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields.
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
}
// UpdateRequest is the body for PUT /products/{id}.

View File

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

View File

@@ -0,0 +1,58 @@
package recipe
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler handles HTTP requests for recipes.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// GetByID handles GET /recipes/{id}.
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
id := chi.URLParam(r, "id")
rec, err := h.repo.GetByID(r.Context(), id)
if err != nil {
slog.Error("get recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe")
return
}
if rec == nil {
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
return
}
writeJSON(w, http.StatusOK, rec)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

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

View File

@@ -2,7 +2,6 @@ package recipe
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -11,7 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for recipes and their translations.
// Repository handles persistence for recipes and their relational sub-tables.
type Repository struct {
pool *pgxpool.Pool
}
@@ -21,77 +20,39 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates a recipe (English canonical content only).
// Conflict is resolved on spoonacular_id.
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
query := `
INSERT INTO recipes (
source, spoonacular_id,
title, description,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (spoonacular_id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
cuisine = EXCLUDED.cuisine,
difficulty = EXCLUDED.difficulty,
prep_time_min = EXCLUDED.prep_time_min,
cook_time_min = EXCLUDED.cook_time_min,
servings = EXCLUDED.servings,
image_url = EXCLUDED.image_url,
calories_per_serving = EXCLUDED.calories_per_serving,
protein_per_serving = EXCLUDED.protein_per_serving,
fat_per_serving = EXCLUDED.fat_per_serving,
carbs_per_serving = EXCLUDED.carbs_per_serving,
fiber_per_serving = EXCLUDED.fiber_per_serving,
ingredients = EXCLUDED.ingredients,
steps = EXCLUDED.steps,
tags = EXCLUDED.tags,
updated_at = now()
RETURNING id, source, spoonacular_id,
title, description,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at`
row := r.pool.QueryRow(ctx, query,
recipe.Source, recipe.SpoonacularID,
recipe.Title, recipe.Description,
recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
recipe.Ingredients, recipe.Steps, recipe.Tags,
)
return scanRecipe(row)
}
// GetByID returns a recipe by UUID, with content resolved for the language
// stored in ctx (falls back to English when no translation exists).
// GetByID returns a recipe with its ingredients and steps.
// Text is resolved for the language stored in ctx (English fallback).
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
lang := locale.FromContext(ctx)
query := `
SELECT r.id, r.source, r.spoonacular_id,
COALESCE(rt.title, r.title) AS title,
COALESCE(rt.description, r.description) AS description,
r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url,
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving,
COALESCE(rt.ingredients, r.ingredients) AS ingredients,
COALESCE(rt.steps, r.steps) AS steps,
r.tags,
r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at
const q = `
SELECT r.id, r.dish_id, r.source, r.difficulty,
r.prep_time_min, r.cook_time_min, r.servings,
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving,
r.carbs_per_serving, r.fiber_per_serving,
rt.notes,
r.created_at, r.updated_at
FROM recipes r
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
WHERE r.id = $1`
row := r.pool.QueryRow(ctx, query, id, lang)
row := r.pool.QueryRow(ctx, q, id, lang)
rec, err := scanRecipe(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
if err != nil {
return nil, fmt.Errorf("get recipe %s: %w", id, err)
}
if err := r.loadIngredients(ctx, rec, lang); err != nil {
return nil, err
}
if err := r.loadSteps(ctx, rec, lang); err != nil {
return nil, err
}
return rec, nil
}
// Count returns the total number of recipes.
@@ -103,97 +64,79 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
return n, nil
}
// ListMissingTranslation returns Spoonacular recipes that have no translation
// for the given language, ordered by review_count DESC.
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) {
query := `
SELECT id, source, spoonacular_id,
title, description,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at
FROM recipes
WHERE source = 'spoonacular'
AND NOT EXISTS (
SELECT 1 FROM recipe_translations rt
WHERE rt.recipe_id = recipes.id AND rt.lang = $3
)
ORDER BY review_count DESC
LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset, lang)
// loadIngredients fills rec.Ingredients from recipe_ingredients.
func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT ri.id, ri.ingredient_id,
COALESCE(rit.name, ri.name) AS name,
ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
FROM recipe_ingredients ri
LEFT JOIN recipe_ingredient_translations rit
ON rit.ri_id = ri.id AND rit.lang = $2
WHERE ri.recipe_id = $1
ORDER BY ri.sort_order`, rec.ID, lang)
if err != nil {
return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err)
}
defer rows.Close()
return collectRecipes(rows)
}
// UpsertTranslation inserts or replaces a recipe translation for a specific language.
func (r *Repository) UpsertTranslation(
ctx context.Context,
id, lang string,
title, description *string,
ingredients, steps json.RawMessage,
) error {
query := `
INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (recipe_id, lang) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
ingredients = EXCLUDED.ingredients,
steps = EXCLUDED.steps`
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err)
for rows.Next() {
var ing RecipeIngredient
if err := rows.Scan(
&ing.ID, &ing.IngredientID, &ing.Name,
&ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder,
); err != nil {
return fmt.Errorf("scan ingredient: %w", err)
}
rec.Ingredients = append(rec.Ingredients, ing)
}
return nil
return rows.Err()
}
// --- helpers ---
// loadSteps fills rec.Steps from recipe_steps.
func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT rs.id, rs.step_number,
COALESCE(rst.description, rs.description) AS description,
rs.timer_seconds, rs.image_url
FROM recipe_steps rs
LEFT JOIN recipe_step_translations rst
ON rst.step_id = rs.id AND rst.lang = $2
WHERE rs.recipe_id = $1
ORDER BY rs.step_number`, rec.ID, lang)
if err != nil {
return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err)
}
defer rows.Close()
for rows.Next() {
var s RecipeStep
if err := rows.Scan(
&s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL,
); err != nil {
return fmt.Errorf("scan step: %w", err)
}
rec.Steps = append(rec.Steps, s)
}
return rows.Err()
}
// --- scan helpers ---
func scanRecipe(row pgx.Row) (*Recipe, error) {
var rec Recipe
var ingredients, steps, tags []byte
err := row.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID,
&rec.Title, &rec.Description,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags,
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
&rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
&rec.CarbsPerServing, &rec.FiberPerServing,
&rec.Notes,
&rec.CreatedAt, &rec.UpdatedAt,
)
if err != nil {
return nil, err
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
rec.Ingredients = []RecipeIngredient{}
rec.Steps = []RecipeStep{}
return &rec, nil
}
func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
var result []*Recipe
for rows.Next() {
var rec Recipe
var ingredients, steps, tags []byte
if err := rows.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID,
&rec.Title, &rec.Description,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags,
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan recipe: %w", err)
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
result = append(result, &rec)
}
return result, rows.Err()
}

View File

@@ -4,132 +4,11 @@ package recipe
import (
"context"
"encoding/json"
"testing"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/testutil"
)
func TestRecipeRepository_Upsert_Insert(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 10001
cuisine := "italian"
diff := "easy"
cookTime := 30
servings := 4
rec := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Pasta Carbonara",
Cuisine: &cuisine,
Difficulty: &diff,
CookTimeMin: &cookTime,
Servings: &servings,
Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`),
Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`),
Tags: json.RawMessage(`["italian"]`),
}
got, err := repo.Upsert(ctx, rec)
if err != nil {
t.Fatalf("upsert: %v", err)
}
if got.ID == "" {
t.Error("expected non-empty ID")
}
if got.Title != "Pasta Carbonara" {
t.Errorf("title: want Pasta Carbonara, got %s", got.Title)
}
if got.SpoonacularID == nil || *got.SpoonacularID != id {
t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID)
}
}
func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 20001
cuisine := "mexican"
diff := "medium"
first := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Tacos",
Cuisine: &cuisine,
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
}
got1, err := repo.Upsert(ctx, first)
if err != nil {
t.Fatalf("first upsert: %v", err)
}
second := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Beef Tacos",
Cuisine: &cuisine,
Difficulty: &diff,
Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
}
got2, err := repo.Upsert(ctx, second)
if err != nil {
t.Fatalf("second upsert: %v", err)
}
if got1.ID != got2.ID {
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
}
if got2.Title != "Beef Tacos" {
t.Errorf("title not updated: got %s", got2.Title)
}
}
func TestRecipeRepository_GetByID_Found(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 30001
diff := "easy"
rec := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Greek Salad",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`["vegetarian"]`),
}
saved, err := repo.Upsert(ctx, rec)
if err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := repo.GetByID(ctx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got == nil {
t.Fatal("expected non-nil result")
}
if got.Title != "Greek Salad" {
t.Errorf("want Greek Salad, got %s", got.Title)
}
}
func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
@@ -144,181 +23,13 @@ func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
}
}
func TestRecipeRepository_ListMissingTranslation_Pagination(t *testing.T) {
func TestRecipeRepository_Count(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
diff := "easy"
for i := 0; i < 5; i++ {
spID := 40000 + i
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &spID,
Title: "Recipe " + string(rune('A'+i)),
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert recipe %d: %v", i, err)
}
}
missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0)
_, err := repo.Count(ctx)
if err != nil {
t.Fatalf("list missing translation: %v", err)
}
if len(missing) != 3 {
t.Errorf("expected 3 results with limit=3, got %d", len(missing))
}
}
func TestRecipeRepository_UpsertTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 50001
diff := "medium"
saved, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Chicken Tikka Masala",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
titleRu := "Курица Тикка Масала"
descRu := "Классическое индийское блюдо"
stepsRu := json.RawMessage(`[{"number":1,"description":"Разогрейте масло"}]`)
if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); err != nil {
t.Fatalf("upsert translation: %v", err)
}
// Retrieve with Russian context — title and steps should be translated.
ruCtx := locale.WithLang(ctx, "ru")
got, err := repo.GetByID(ruCtx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.Title != titleRu {
t.Errorf("expected title=%q, got %q", titleRu, got.Title)
}
if got.Description == nil || *got.Description != descRu {
t.Errorf("expected description=%q, got %v", descRu, got.Description)
}
var steps []RecipeStep
if err := json.Unmarshal(got.Steps, &steps); err != nil {
t.Fatalf("unmarshal steps: %v", err)
}
if len(steps) == 0 || steps[0].Description != "Разогрейте масло" {
t.Errorf("expected Russian step description, got %v", steps)
}
// Retrieve with English context — should return original English content.
enCtx := locale.WithLang(ctx, "en")
gotEn, err := repo.GetByID(enCtx, saved.ID)
if err != nil {
t.Fatalf("get by id (en): %v", err)
}
if gotEn.Title != "Chicken Tikka Masala" {
t.Errorf("expected English title, got %q", gotEn.Title)
}
}
func TestRecipeRepository_ListMissingTranslation_ExcludesTranslated(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
diff := "easy"
// Insert untranslated recipes.
for i := 0; i < 3; i++ {
spID := 60000 + i
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &spID,
Title: "Untranslated " + string(rune('A'+i)),
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
}
// Insert one recipe and add a Russian translation.
spID := 60100
translated, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &spID,
Title: "Translated Recipe",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert translated: %v", err)
}
titleRu := "Переведённый рецепт"
if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil {
t.Fatalf("upsert translation: %v", err)
}
missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
if err != nil {
t.Fatalf("list missing translation: %v", err)
}
for _, r := range missing {
if r.Title == "Translated Recipe" {
t.Error("translated recipe should not appear in ListMissingTranslation")
}
}
if len(missing) < 3 {
t.Errorf("expected at least 3 missing, got %d", len(missing))
}
}
func TestRecipeRepository_GIN_Tags(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 70001
diff := "easy"
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Veggie Bowl",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`["vegetarian","gluten-free"]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
// GIN index query: tags @> '["vegetarian"]'
var count int
row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id)
if err := row.Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("expected 1 vegetarian recipe, got %d", count)
t.Fatalf("count: %v", err)
}
}

View File

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

View File

@@ -1,43 +1,76 @@
package savedrecipe
import (
"encoding/json"
"time"
)
import "time"
// SavedRecipe is a recipe saved by a specific user.
type SavedRecipe struct {
ID string `json:"id"`
UserID string `json:"-"`
Title string `json:"title"`
Description *string `json:"description"`
Cuisine *string `json:"cuisine"`
Difficulty *string `json:"difficulty"`
PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"`
ImageURL *string `json:"image_url"`
Ingredients json.RawMessage `json:"ingredients"`
Steps json.RawMessage `json:"steps"`
Tags json.RawMessage `json:"tags"`
Nutrition json.RawMessage `json:"nutrition_per_serving"`
Source string `json:"source"`
SavedAt time.Time `json:"saved_at"`
// UserSavedRecipe is a user's bookmark referencing a catalog recipe.
// Display fields are populated by joining dishes + recipes.
type UserSavedRecipe struct {
ID string `json:"id"`
UserID string `json:"-"`
RecipeID string `json:"recipe_id"`
SavedAt time.Time `json:"saved_at"`
// Display data — joined from dishes + recipes.
DishName string `json:"title"` // dish name used as display title
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
CuisineSlug *string `json:"cuisine_slug"`
Tags []string `json:"tags"`
Difficulty *string `json:"difficulty"`
PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"`
CaloriesPerServing *float64 `json:"calories_per_serving"`
ProteinPerServing *float64 `json:"protein_per_serving"`
FatPerServing *float64 `json:"fat_per_serving"`
CarbsPerServing *float64 `json:"carbs_per_serving"`
Ingredients []RecipeIngredient `json:"ingredients"`
Steps []RecipeStep `json:"steps"`
}
// RecipeIngredient is a single ingredient row.
type RecipeIngredient struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
UnitCode *string `json:"unit_code"`
IsOptional bool `json:"is_optional"`
}
// RecipeStep is a single step row.
type RecipeStep struct {
StepNumber int `json:"number"`
Description string `json:"description"`
TimerSeconds *int `json:"timer_seconds"`
}
// SaveRequest is the body for POST /saved-recipes.
// When recipe_id is provided, the existing catalog recipe is bookmarked.
// Otherwise a new dish+recipe is created from the supplied fields.
type SaveRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Cuisine string `json:"cuisine"`
Difficulty string `json:"difficulty"`
PrepTimeMin int `json:"prep_time_min"`
CookTimeMin int `json:"cook_time_min"`
Servings int `json:"servings"`
ImageURL string `json:"image_url"`
Ingredients json.RawMessage `json:"ingredients"`
Steps json.RawMessage `json:"steps"`
Tags json.RawMessage `json:"tags"`
Nutrition json.RawMessage `json:"nutrition_per_serving"`
Source string `json:"source"`
RecipeID string `json:"recipe_id"` // optional: bookmark existing recipe
Title string `json:"title"`
Description string `json:"description"`
Cuisine string `json:"cuisine"`
Difficulty string `json:"difficulty"`
PrepTimeMin int `json:"prep_time_min"`
CookTimeMin int `json:"cook_time_min"`
Servings int `json:"servings"`
ImageURL string `json:"image_url"`
// Ingredients / Steps / Tags / Nutrition are JSONB for backward compatibility
// with the recommendation flow that sends the full Gemini response.
Ingredients interface{} `json:"ingredients"`
Steps interface{} `json:"steps"`
Tags interface{} `json:"tags"`
Nutrition interface{} `json:"nutrition_per_serving"`
Source string `json:"source"`
}
// ErrNotFound is returned when a saved recipe does not exist for the given user.
var ErrNotFound = errorString("saved recipe not found")
type errorString string
func (e errorString) Error() string { return string(e) }

View File

@@ -6,132 +6,244 @@ import (
"errors"
"fmt"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a saved recipe does not exist for the given user.
var ErrNotFound = errors.New("saved recipe not found")
// Repository handles persistence for saved recipes and their translations.
// Repository handles persistence for user_saved_recipes.
type Repository struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
dishRepo *dish.Repository
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository {
return &Repository{pool: pool, dishRepo: dishRepo}
}
// Save persists a recipe for userID and returns the stored record.
// The canonical content (any language) is stored directly in saved_recipes.
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
const query = `
INSERT INTO saved_recipes (
user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source, saved_at`
// Save bookmarks a recipe for the user.
// If req.RecipeID is set, that existing catalog recipe is bookmarked.
// Otherwise a new dish + recipe is created from the supplied fields.
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) {
recipeID := req.RecipeID
description := nullableStr(req.Description)
cuisine := nullableStr(req.Cuisine)
difficulty := nullableStr(req.Difficulty)
imageURL := nullableStr(req.ImageURL)
prepTime := nullableInt(req.PrepTimeMin)
cookTime := nullableInt(req.CookTimeMin)
servings := nullableInt(req.Servings)
if recipeID == "" {
// Build a dish.CreateRequest from the save body.
cr := dish.CreateRequest{
Name: req.Title,
Description: req.Description,
CuisineSlug: mapCuisineSlug(req.Cuisine),
ImageURL: req.ImageURL,
Source: req.Source,
Difficulty: req.Difficulty,
PrepTimeMin: req.PrepTimeMin,
CookTimeMin: req.CookTimeMin,
Servings: req.Servings,
}
source := req.Source
if source == "" {
source = "ai"
// Unmarshal ingredients.
if req.Ingredients != nil {
switch v := req.Ingredients.(type) {
case []interface{}:
for i, item := range v {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
ing := dish.IngredientInput{
Name: strVal(m["name"]),
Amount: floatVal(m["amount"]),
Unit: strVal(m["unit"]),
}
cr.Ingredients = append(cr.Ingredients, ing)
_ = i
}
case json.RawMessage:
var items []dish.IngredientInput
if err := json.Unmarshal(v, &items); err == nil {
cr.Ingredients = items
}
}
}
// Unmarshal steps.
if req.Steps != nil {
switch v := req.Steps.(type) {
case []interface{}:
for i, item := range v {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
num := int(floatVal(m["number"]))
if num <= 0 {
num = i + 1
}
step := dish.StepInput{
Number: num,
Description: strVal(m["description"]),
}
cr.Steps = append(cr.Steps, step)
}
case json.RawMessage:
var items []dish.StepInput
if err := json.Unmarshal(v, &items); err == nil {
cr.Steps = items
}
}
}
// Unmarshal tags.
if req.Tags != nil {
switch v := req.Tags.(type) {
case []interface{}:
for _, t := range v {
if s, ok := t.(string); ok {
cr.Tags = append(cr.Tags, s)
}
}
case json.RawMessage:
var items []string
if err := json.Unmarshal(v, &items); err == nil {
cr.Tags = items
}
}
}
// Unmarshal nutrition.
if req.Nutrition != nil {
switch v := req.Nutrition.(type) {
case map[string]interface{}:
cr.Calories = floatVal(v["calories"])
cr.Protein = floatVal(v["protein_g"])
cr.Fat = floatVal(v["fat_g"])
cr.Carbs = floatVal(v["carbs_g"])
case json.RawMessage:
var nut struct {
Calories float64 `json:"calories"`
Protein float64 `json:"protein_g"`
Fat float64 `json:"fat_g"`
Carbs float64 `json:"carbs_g"`
}
if err := json.Unmarshal(v, &nut); err == nil {
cr.Calories = nut.Calories
cr.Protein = nut.Protein
cr.Fat = nut.Fat
cr.Carbs = nut.Carbs
}
}
}
var err error
recipeID, err = r.dishRepo.Create(ctx, cr)
if err != nil {
return nil, fmt.Errorf("create dish+recipe: %w", err)
}
}
ingredients := defaultJSONArray(req.Ingredients)
steps := defaultJSONArray(req.Steps)
tags := defaultJSONArray(req.Tags)
// Insert bookmark.
const q = `
INSERT INTO user_saved_recipes (user_id, recipe_id)
VALUES ($1, $2)
ON CONFLICT (user_id, recipe_id) DO UPDATE SET saved_at = now()
RETURNING id, user_id, recipe_id, saved_at`
row := r.pool.QueryRow(ctx, query,
userID, req.Title, description, cuisine, difficulty,
prepTime, cookTime, servings, imageURL,
ingredients, steps, tags, req.Nutrition, source,
var usr UserSavedRecipe
err := r.pool.QueryRow(ctx, q, userID, recipeID).Scan(
&usr.ID, &usr.UserID, &usr.RecipeID, &usr.SavedAt,
)
return scanRow(row)
if err != nil {
return nil, fmt.Errorf("insert bookmark: %w", err)
}
return r.enrichOne(ctx, &usr)
}
// List returns all saved recipes for userID ordered by saved_at DESC.
// Text content (title, description, ingredients, steps) is resolved for the
// language stored in ctx, falling back to the canonical content when no
// translation exists.
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
// List returns all bookmarked recipes for userID ordered by saved_at DESC.
func (r *Repository) List(ctx context.Context, userID string) ([]*UserSavedRecipe, error) {
lang := locale.FromContext(ctx)
const query = `
SELECT sr.id, sr.user_id,
COALESCE(srt.title, sr.title) AS title,
COALESCE(srt.description, sr.description) AS description,
sr.cuisine, sr.difficulty,
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
COALESCE(srt.steps, sr.steps) AS steps,
sr.tags, sr.nutrition, sr.source, sr.saved_at
FROM saved_recipes sr
LEFT JOIN saved_recipe_translations srt
ON srt.saved_recipe_id = sr.id AND srt.lang = $2
WHERE sr.user_id = $1
ORDER BY sr.saved_at DESC`
const q = `
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
COALESCE(dt.name, d.name) AS dish_name,
COALESCE(dt.description, d.description) AS description,
d.image_url, d.cuisine_slug,
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
FROM user_saved_recipes usr
JOIN recipes r ON r.id = usr.recipe_id
JOIN dishes d ON d.id = r.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
WHERE usr.user_id = $1
ORDER BY usr.saved_at DESC`
rows, err := r.pool.Query(ctx, query, userID, lang)
rows, err := r.pool.Query(ctx, q, userID, lang)
if err != nil {
return nil, fmt.Errorf("list saved recipes: %w", err)
}
defer rows.Close()
var result []*SavedRecipe
var result []*UserSavedRecipe
for rows.Next() {
rec, err := scanRow(rows)
rec, err := scanUSR(rows)
if err != nil {
return nil, fmt.Errorf("scan saved recipe: %w", err)
}
if err := r.loadTags(ctx, rec); err != nil {
return nil, err
}
if err := r.loadIngredients(ctx, rec, lang); err != nil {
return nil, err
}
if err := r.loadSteps(ctx, rec, lang); err != nil {
return nil, err
}
result = append(result, rec)
}
return result, rows.Err()
}
// GetByID returns the saved recipe with id for userID, or nil if not found.
// Text content is resolved for the language stored in ctx.
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
// GetByID returns a bookmarked recipe by its bookmark ID for userID.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) {
lang := locale.FromContext(ctx)
const query = `
SELECT sr.id, sr.user_id,
COALESCE(srt.title, sr.title) AS title,
COALESCE(srt.description, sr.description) AS description,
sr.cuisine, sr.difficulty,
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
COALESCE(srt.steps, sr.steps) AS steps,
sr.tags, sr.nutrition, sr.source, sr.saved_at
FROM saved_recipes sr
LEFT JOIN saved_recipe_translations srt
ON srt.saved_recipe_id = sr.id AND srt.lang = $3
WHERE sr.id = $1 AND sr.user_id = $2`
const q = `
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
COALESCE(dt.name, d.name) AS dish_name,
COALESCE(dt.description, d.description) AS description,
d.image_url, d.cuisine_slug,
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
FROM user_saved_recipes usr
JOIN recipes r ON r.id = usr.recipe_id
JOIN dishes d ON d.id = r.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
WHERE usr.id = $1 AND usr.user_id = $2`
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID, lang))
rec, err := scanUSR(r.pool.QueryRow(ctx, q, id, userID, lang))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
if err != nil {
return nil, err
}
if err := r.loadTags(ctx, rec); err != nil {
return nil, err
}
if err := r.loadIngredients(ctx, rec, lang); err != nil {
return nil, err
}
if err := r.loadSteps(ctx, rec, lang); err != nil {
return nil, err
}
return rec, nil
}
// Delete removes the saved recipe with id for userID.
// Returns ErrNotFound if the record does not exist.
// Delete removes a bookmark.
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`,
id, userID,
)
`DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete saved recipe: %w", err)
}
@@ -141,73 +253,170 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error {
return nil
}
// UpsertTranslation inserts or replaces a translation for a saved recipe.
func (r *Repository) UpsertTranslation(
ctx context.Context,
id, lang string,
title, description *string,
ingredients, steps json.RawMessage,
) error {
const query = `
INSERT INTO saved_recipe_translations (saved_recipe_id, lang, title, description, ingredients, steps)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (saved_recipe_id, lang) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
ingredients = EXCLUDED.ingredients,
steps = EXCLUDED.steps,
generated_at = now()`
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
return fmt.Errorf("upsert saved recipe translation %s/%s: %w", id, lang, err)
}
return nil
}
// --- helpers ---
type scannable interface {
func (r *Repository) enrichOne(ctx context.Context, usr *UserSavedRecipe) (*UserSavedRecipe, error) {
lang := locale.FromContext(ctx)
const q = `
SELECT COALESCE(dt.name, d.name) AS dish_name,
COALESCE(dt.description, d.description) AS description,
d.image_url, d.cuisine_slug,
rec.difficulty, rec.prep_time_min, rec.cook_time_min, rec.servings,
rec.calories_per_serving, rec.protein_per_serving,
rec.fat_per_serving, rec.carbs_per_serving
FROM recipes rec
JOIN dishes d ON d.id = rec.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
WHERE rec.id = $1`
row := r.pool.QueryRow(ctx, q, usr.RecipeID, lang)
if err := row.Scan(
&usr.DishName, &usr.Description, &usr.ImageURL, &usr.CuisineSlug,
&usr.Difficulty, &usr.PrepTimeMin, &usr.CookTimeMin, &usr.Servings,
&usr.CaloriesPerServing, &usr.ProteinPerServing, &usr.FatPerServing, &usr.CarbsPerServing,
); err != nil {
return nil, fmt.Errorf("enrich saved recipe: %w", err)
}
if err := r.loadTags(ctx, usr); err != nil {
return nil, err
}
if err := r.loadIngredients(ctx, usr, lang); err != nil {
return nil, err
}
return usr, r.loadSteps(ctx, usr, lang)
}
func (r *Repository) loadTags(ctx context.Context, usr *UserSavedRecipe) error {
rows, err := r.pool.Query(ctx, `
SELECT dt.tag_slug
FROM dish_tags dt
JOIN recipes rec ON rec.dish_id = dt.dish_id
JOIN user_saved_recipes usr ON usr.recipe_id = rec.id
WHERE usr.id = $1
ORDER BY dt.tag_slug`, usr.ID)
if err != nil {
return fmt.Errorf("load tags for saved recipe %s: %w", usr.ID, err)
}
defer rows.Close()
usr.Tags = []string{}
for rows.Next() {
var slug string
if err := rows.Scan(&slug); err != nil {
return err
}
usr.Tags = append(usr.Tags, slug)
}
return rows.Err()
}
func (r *Repository) loadIngredients(ctx context.Context, usr *UserSavedRecipe, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT COALESCE(rit.name, ri.name) AS name,
ri.amount, ri.unit_code, ri.is_optional
FROM recipe_ingredients ri
LEFT JOIN recipe_ingredient_translations rit
ON rit.ri_id = ri.id AND rit.lang = $2
WHERE ri.recipe_id = $1
ORDER BY ri.sort_order`, usr.RecipeID, lang)
if err != nil {
return fmt.Errorf("load ingredients for saved recipe %s: %w", usr.ID, err)
}
defer rows.Close()
usr.Ingredients = []RecipeIngredient{}
for rows.Next() {
var ing RecipeIngredient
if err := rows.Scan(&ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional); err != nil {
return err
}
usr.Ingredients = append(usr.Ingredients, ing)
}
return rows.Err()
}
func (r *Repository) loadSteps(ctx context.Context, usr *UserSavedRecipe, lang string) error {
rows, err := r.pool.Query(ctx, `
SELECT rs.step_number,
COALESCE(rst.description, rs.description) AS description,
rs.timer_seconds
FROM recipe_steps rs
LEFT JOIN recipe_step_translations rst
ON rst.step_id = rs.id AND rst.lang = $2
WHERE rs.recipe_id = $1
ORDER BY rs.step_number`, usr.RecipeID, lang)
if err != nil {
return fmt.Errorf("load steps for saved recipe %s: %w", usr.ID, err)
}
defer rows.Close()
usr.Steps = []RecipeStep{}
for rows.Next() {
var s RecipeStep
if err := rows.Scan(&s.StepNumber, &s.Description, &s.TimerSeconds); err != nil {
return err
}
usr.Steps = append(usr.Steps, s)
}
return rows.Err()
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanRow(s scannable) (*SavedRecipe, error) {
var rec SavedRecipe
var ingredients, steps, tags, nutrition []byte
func scanUSR(s rowScanner) (*UserSavedRecipe, error) {
var r UserSavedRecipe
err := s.Scan(
&rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty,
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&ingredients, &steps, &tags, &nutrition,
&rec.Source, &rec.SavedAt,
&r.ID, &r.UserID, &r.RecipeID, &r.SavedAt,
&r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug,
&r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings,
&r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing,
)
if err != nil {
return nil, err
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
if len(nutrition) > 0 {
rec.Nutrition = json.RawMessage(nutrition)
}
return &rec, nil
return &r, err
}
func nullableStr(s string) *string {
if s == "" {
return nil
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
// Falls back to "other".
func mapCuisineSlug(cuisine string) string {
known := map[string]string{
"russian": "russian",
"italian": "italian",
"french": "french",
"chinese": "chinese",
"japanese": "japanese",
"korean": "korean",
"mexican": "mexican",
"mediterranean": "mediterranean",
"indian": "indian",
"thai": "thai",
"american": "american",
"georgian": "georgian",
"spanish": "spanish",
"german": "german",
"middle_eastern":"middle_eastern",
"turkish": "turkish",
"greek": "greek",
"vietnamese": "vietnamese",
"asian": "other",
"european": "other",
}
return &s
if s, ok := known[cuisine]; ok {
return s
}
return "other"
}
func nullableInt(n int) *int {
if n <= 0 {
return nil
func strVal(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return &n
return ""
}
func defaultJSONArray(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return json.RawMessage(`[]`)
func floatVal(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
}
return raw
return 0
}

View File

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

View File

@@ -0,0 +1,28 @@
package tag
import (
"encoding/json"
"net/http"
"github.com/food-ai/backend/internal/locale"
)
type tagItem struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
// List handles GET /tags — returns tags with names in the requested language.
func List(w http.ResponseWriter, r *http.Request) {
lang := locale.FromContext(r.Context())
items := make([]tagItem, 0, len(Records))
for _, t := range Records {
name, ok := t.Translations[lang]
if !ok {
name = t.Name
}
items = append(items, tagItem{Slug: t.Slug, Name: name})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"tags": items})
}

View File

@@ -0,0 +1,79 @@
package tag
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Record is a tag loaded from DB with all its translations.
type Record struct {
Slug string
Name string // English canonical name
SortOrder int
Translations map[string]string // lang → localized name
}
// Records is the ordered list of tags, populated by LoadFromDB at startup.
var Records []Record
// LoadFromDB queries tags + tag_translations and populates Records.
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
rows, err := pool.Query(ctx, `
SELECT t.slug, t.name, t.sort_order, tt.lang, tt.name
FROM tags t
LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug
ORDER BY t.sort_order, tt.lang`)
if err != nil {
return fmt.Errorf("load tags from db: %w", err)
}
defer rows.Close()
bySlug := map[string]*Record{}
var order []string
for rows.Next() {
var slug, engName string
var sortOrder int
var lang, name *string
if err := rows.Scan(&slug, &engName, &sortOrder, &lang, &name); err != nil {
return err
}
if _, ok := bySlug[slug]; !ok {
bySlug[slug] = &Record{
Slug: slug,
Name: engName,
SortOrder: sortOrder,
Translations: map[string]string{},
}
order = append(order, slug)
}
if lang != nil && name != nil {
bySlug[slug].Translations[*lang] = *name
}
}
if err := rows.Err(); err != nil {
return err
}
result := make([]Record, 0, len(order))
for _, slug := range order {
result = append(result, *bySlug[slug])
}
Records = result
return nil
}
// NameFor returns the localized name for a tag slug.
// Falls back to the English canonical name.
func NameFor(slug, lang string) string {
for _, t := range Records {
if t.Slug == slug {
if name, ok := t.Translations[lang]; ok {
return name
}
return t.Name
}
}
return slug
}