feat: implement Iteration 1 — AI recipe recommendations

Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
  retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go

Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()

Project:
- Add CLAUDE.md with English-only rule for comments and commit messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

View File

@@ -20,6 +20,10 @@ type Config struct {
// CORS
AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"`
// External APIs
GeminiAPIKey string `envconfig:"GEMINI_API_KEY" required:"true"`
PexelsAPIKey string `envconfig:"PEXELS_API_KEY" required:"true"`
}
func Load() (*Config, error) {

View File

@@ -0,0 +1,81 @@
package gemini
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
// Groq — OpenAI-compatible API, free tier, no billing required.
groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
groqModel = "llama-3.3-70b-versatile"
maxRetries = 3
)
// Client is an HTTP client for the Groq LLM API (OpenAI-compatible).
type Client struct {
apiKey string
httpClient *http.Client
}
// NewClient creates a new Client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
// generateContent sends a user prompt to Groq and returns the assistant text.
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
body := map[string]any{
"model": groqModel,
"temperature": 0.7,
"messages": messages,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, groqAPIURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("groq API error %d: %s", resp.StatusCode, string(raw))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("empty response from Groq")
}
return result.Choices[0].Message.Content, nil
}

View File

@@ -0,0 +1,186 @@
package gemini
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// RecipeGenerator generates recipes using the Gemini AI.
type RecipeGenerator interface {
GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error)
}
// RecipeRequest contains parameters for recipe generation.
type RecipeRequest struct {
UserGoal string // "weight_loss" | "maintain" | "gain"
DailyCalories int
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
CuisinePrefs []string // e.g. ["russian", "asian"]
Count int
}
// Recipe is a recipe returned by Gemini.
type Recipe 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"`
ImageQuery string `json:"image_query"`
ImageURL string `json:"image_url"`
Ingredients []Ingredient `json:"ingredients"`
Steps []Step `json:"steps"`
Tags []string `json:"tags"`
Nutrition NutritionInfo `json:"nutrition_per_serving"`
}
// Ingredient is a single ingredient in a recipe.
type Ingredient struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
}
// Step is a single preparation step.
type Step struct {
Number int `json:"number"`
Description string `json:"description"`
TimerSeconds *int `json:"timer_seconds"`
}
// NutritionInfo contains approximate nutritional information per serving.
type NutritionInfo struct {
Calories float64 `json:"calories"`
ProteinG float64 `json:"protein_g"`
FatG float64 `json:"fat_g"`
CarbsG float64 `json:"carbs_g"`
Approximate bool `json:"approximate"`
}
// GenerateRecipes generates recipes using the Gemini AI.
// Retries up to maxRetries times only when the response is not valid JSON.
// API-level errors (rate limits, auth, etc.) are returned immediately.
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
prompt := buildRecipePrompt(req)
// OpenAI-compatible messages format used by Groq.
messages := []map[string]string{
{"role": "user", "content": prompt},
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
messages = []map[string]string{
{"role": "user", "content": prompt},
{"role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после."},
}
}
text, err := c.generateContent(ctx, messages)
if err != nil {
// API-level error (4xx/5xx): no point retrying immediately.
return nil, err
}
recipes, err := parseRecipesJSON(text)
if err != nil {
// Malformed JSON from the model — retry with a clarifying message.
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
continue
}
for i := range recipes {
recipes[i].Nutrition.Approximate = true
}
return recipes, nil
}
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
}
func buildRecipePrompt(req RecipeRequest) string {
goalRu := map[string]string{
"weight_loss": "похудение",
"maintain": "поддержание веса",
"gain": "набор массы",
}
goal := goalRu[req.UserGoal]
if goal == "" {
goal = "поддержание веса"
}
restrictions := "нет"
if len(req.Restrictions) > 0 {
restrictions = strings.Join(req.Restrictions, ", ")
}
cuisines := "любые"
if len(req.CuisinePrefs) > 0 {
cuisines = strings.Join(req.CuisinePrefs, ", ")
}
count := req.Count
if count <= 0 {
count = 5
}
perMealCalories := req.DailyCalories / 3
if perMealCalories <= 0 {
perMealCalories = 600
}
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке.
Профиль пользователя:
- Цель: %s
- Дневная норма калорий: %d ккал
- Ограничения: %s
- Предпочтения: %s
Требования к каждому рецепту:
- Калорийность на порцию: не более %d ккал
- Время приготовления: до 60 минут
- Укажи КБЖУ на порцию (приблизительно)
ВАЖНО: поле image_query заполняй ТОЛЬКО на английском языке — оно используется для поиска фото.
Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки:
[{
"title": "Название",
"description": "2-3 предложения",
"cuisine": "russian|asian|european|mediterranean|american|other",
"difficulty": "easy|medium|hard",
"prep_time_min": 10,
"cook_time_min": 20,
"servings": 2,
"image_query": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)",
"ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}],
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["высокий белок"],
"nutrition_per_serving": {
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18
}
}]`, count, goal, req.DailyCalories, restrictions, cuisines, perMealCalories)
}
func parseRecipesJSON(text string) ([]Recipe, error) {
text = strings.TrimSpace(text)
// Strip potential markdown code fences
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
var recipes []Recipe
if err := json.Unmarshal([]byte(text), &recipes); err != nil {
return nil, err
}
return recipes, nil
}

View File

@@ -0,0 +1,28 @@
package ingredient
import (
"encoding/json"
"time"
)
// IngredientMapping is the canonical ingredient record used to link
// user products, recipe ingredients, and Spoonacular data.
type IngredientMapping struct {
ID string `json:"id"`
CanonicalName string `json:"canonical_name"`
CanonicalNameRu *string `json:"canonical_name_ru"`
SpoonacularID *int `json:"spoonacular_id"`
Aliases json.RawMessage `json:"aliases"` // []string
Category *string `json:"category"`
DefaultUnit *string `json:"default_unit"`
CaloriesPer100g *float64 `json:"calories_per_100g"`
ProteinPer100g *float64 `json:"protein_per_100g"`
FatPer100g *float64 `json:"fat_per_100g"`
CarbsPer100g *float64 `json:"carbs_per_100g"`
FiberPer100g *float64 `json:"fiber_per_100g"`
StorageDays *int `json:"storage_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,185 @@
package ingredient
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for ingredient_mappings.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates an ingredient mapping.
// Conflict is resolved on spoonacular_id when set; otherwise a simple insert is done.
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
query := `
INSERT INTO ingredient_mappings (
canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (spoonacular_id) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name,
aliases = EXCLUDED.aliases,
category = EXCLUDED.category,
default_unit = EXCLUDED.default_unit,
calories_per_100g = EXCLUDED.calories_per_100g,
protein_per_100g = EXCLUDED.protein_per_100g,
fat_per_100g = EXCLUDED.fat_per_100g,
carbs_per_100g = EXCLUDED.carbs_per_100g,
fiber_per_100g = EXCLUDED.fiber_per_100g,
storage_days = EXCLUDED.storage_days,
updated_at = now()
RETURNING id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at`
row := r.pool.QueryRow(ctx, query,
m.CanonicalName, m.CanonicalNameRu, m.SpoonacularID, m.Aliases,
m.Category, m.DefaultUnit,
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
m.StorageDays,
)
return scanMapping(row)
}
// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID.
// Returns nil, nil if not found.
func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) {
query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at
FROM ingredient_mappings
WHERE spoonacular_id = $1`
row := r.pool.QueryRow(ctx, query, id)
m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return m, err
}
// GetByID returns an ingredient mapping by UUID.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at
FROM ingredient_mappings
WHERE id = $1`
row := r.pool.QueryRow(ctx, query, id)
m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return m, err
}
// Count returns the total number of ingredient mappings.
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)
}
return n, nil
}
// ListUntranslated returns ingredients without a Russian name, ordered by id.
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*IngredientMapping, error) {
query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at
FROM ingredient_mappings
WHERE canonical_name_ru IS NULL
ORDER BY id
LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("list untranslated: %w", err)
}
defer rows.Close()
return collectMappings(rows)
}
// UpdateTranslation saves the Russian name and adds Russian aliases.
func (r *Repository) UpdateTranslation(ctx context.Context, id, canonicalNameRu string, aliasesRu []string) error {
// Merge new aliases into existing JSONB array without duplicates
query := `
UPDATE ingredient_mappings SET
canonical_name_ru = $2,
aliases = (
SELECT jsonb_agg(DISTINCT elem)
FROM (
SELECT jsonb_array_elements(aliases) AS elem
UNION
SELECT to_jsonb(unnest) FROM unnest($3::text[]) AS unnest
) sub
),
updated_at = now()
WHERE id = $1`
if _, err := r.pool.Exec(ctx, query, id, canonicalNameRu, aliasesRu); err != nil {
return fmt.Errorf("update translation %s: %w", id, err)
}
return nil
}
// --- helpers ---
func scanMapping(row pgx.Row) (*IngredientMapping, error) {
var m IngredientMapping
var aliases []byte
err := row.Scan(
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
&m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
m.Aliases = json.RawMessage(aliases)
return &m, nil
}
func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) {
var result []*IngredientMapping
for rows.Next() {
var m IngredientMapping
var aliases []byte
if err := rows.Scan(
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
&m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan mapping: %w", err)
}
m.Aliases = json.RawMessage(aliases)
result = append(result, &m)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,250 @@
//go:build integration
package ingredient
import (
"context"
"encoding/json"
"testing"
"github.com/food-ai/backend/internal/testutil"
)
func TestIngredientRepository_Upsert_Insert(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 1001
cat := "produce"
unit := "g"
cal := 52.0
m := &IngredientMapping{
CanonicalName: "apple",
SpoonacularID: &id,
Aliases: json.RawMessage(`["apple", "apples"]`),
Category: &cat,
DefaultUnit: &unit,
CaloriesPer100g: &cal,
}
got, err := repo.Upsert(ctx, m)
if err != nil {
t.Fatalf("upsert: %v", err)
}
if got.ID == "" {
t.Error("expected non-empty ID")
}
if got.CanonicalName != "apple" {
t.Errorf("canonical_name: want apple, got %s", got.CanonicalName)
}
if *got.CaloriesPer100g != 52.0 {
t.Errorf("calories: want 52.0, got %v", got.CaloriesPer100g)
}
}
func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 2001
cat := "produce"
unit := "g"
first := &IngredientMapping{
CanonicalName: "banana",
SpoonacularID: &id,
Aliases: json.RawMessage(`["banana"]`),
Category: &cat,
DefaultUnit: &unit,
}
got1, err := repo.Upsert(ctx, first)
if err != nil {
t.Fatalf("first upsert: %v", err)
}
// Update with same spoonacular_id
cal := 89.0
second := &IngredientMapping{
CanonicalName: "banana_updated",
SpoonacularID: &id,
Aliases: json.RawMessage(`["banana", "bananas"]`),
Category: &cat,
DefaultUnit: &unit,
CaloriesPer100g: &cal,
}
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.CanonicalName != "banana_updated" {
t.Errorf("canonical_name not updated: got %s", got2.CanonicalName)
}
if got2.CaloriesPer100g == nil || *got2.CaloriesPer100g != 89.0 {
t.Errorf("calories not updated: got %v", got2.CaloriesPer100g)
}
}
func TestIngredientRepository_GetBySpoonacularID_Found(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 3001
cat := "dairy"
unit := "g"
_, err := repo.Upsert(ctx, &IngredientMapping{
CanonicalName: "cheese",
SpoonacularID: &id,
Aliases: json.RawMessage(`["cheese"]`),
Category: &cat,
DefaultUnit: &unit,
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := repo.GetBySpoonacularID(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected non-nil result")
}
if got.CanonicalName != "cheese" {
t.Errorf("want cheese, got %s", got.CanonicalName)
}
}
func TestIngredientRepository_GetBySpoonacularID_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
got, err := repo.GetBySpoonacularID(ctx, 99999999)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Error("expected nil result for missing ID")
}
}
func TestIngredientRepository_ListUntranslated(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
cat := "produce"
unit := "g"
// Insert 3 without translation
for i, name := range []string{"carrot", "onion", "garlic"} {
id := 4000 + i
_, err := repo.Upsert(ctx, &IngredientMapping{
CanonicalName: name,
SpoonacularID: &id,
Aliases: json.RawMessage(`[]`),
Category: &cat,
DefaultUnit: &unit,
})
if err != nil {
t.Fatalf("upsert %s: %v", name, err)
}
}
// Insert 1 with translation (shouldn't appear in untranslated list)
id := 4100
ruName := "помидор"
withTranslation := &IngredientMapping{
CanonicalName: "tomato",
CanonicalNameRu: &ruName,
SpoonacularID: &id,
Aliases: json.RawMessage(`[]`),
Category: &cat,
DefaultUnit: &unit,
}
saved, err := repo.Upsert(ctx, withTranslation)
if err != nil {
t.Fatalf("upsert with translation: %v", err)
}
// The upsert doesn't set canonical_name_ru because the UPDATE clause doesn't include it
// We need to manually set it after
if err := repo.UpdateTranslation(ctx, saved.ID, "помидор", []string{"помидор", "томат"}); err != nil {
t.Fatalf("update translation: %v", err)
}
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
if err != nil {
t.Fatalf("list untranslated: %v", err)
}
// Should return the 3 without translation (carrot, onion, garlic)
// The translated tomato should not appear
for _, m := range untranslated {
if m.CanonicalName == "tomato" {
t.Error("translated ingredient should not appear in ListUntranslated")
}
}
if len(untranslated) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
}
}
func TestIngredientRepository_UpdateTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 5001
cat := "meat"
unit := "g"
saved, err := repo.Upsert(ctx, &IngredientMapping{
CanonicalName: "chicken_breast",
SpoonacularID: &id,
Aliases: json.RawMessage(`["chicken breast"]`),
Category: &cat,
DefaultUnit: &unit,
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
err = repo.UpdateTranslation(ctx, saved.ID, "куриная грудка",
[]string{"куриная грудка", "куриное филе"})
if err != nil {
t.Fatalf("update translation: %v", err)
}
got, err := repo.GetByID(ctx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.CanonicalNameRu == nil || *got.CanonicalNameRu != "куриная грудка" {
t.Errorf("expected canonical_name_ru='куриная грудка', got %v", got.CanonicalNameRu)
}
var aliases []string
if err := json.Unmarshal(got.Aliases, &aliases); err != nil {
t.Fatalf("unmarshal aliases: %v", err)
}
hasRu := false
for _, a := range aliases {
if a == "куриное филе" {
hasRu = true
break
}
}
if !hasRu {
t.Errorf("Russian alias not found in aliases: %v", aliases)
}
}

View File

@@ -0,0 +1,77 @@
package pexels
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const (
pexelsSearchURL = "https://api.pexels.com/v1/search"
defaultPlaceholder = "https://images.pexels.com/photos/1640777/pexels-photo-1640777.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750"
)
// PhotoSearcher can search for a photo by text query.
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error)
}
// Client is an HTTP client for the Pexels Photos API.
type Client struct {
apiKey string
httpClient *http.Client
}
// NewClient creates a new Pexels client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// SearchPhoto searches for a landscape photo matching query.
// Returns a default placeholder URL if no photo is found or on error.
func (c *Client) SearchPhoto(ctx context.Context, query string) (string, error) {
params := url.Values{}
params.Set("query", query)
params.Set("per_page", "1")
params.Set("orientation", "landscape")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pexelsSearchURL+"?"+params.Encode(), nil)
if err != nil {
return defaultPlaceholder, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return defaultPlaceholder, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return defaultPlaceholder, fmt.Errorf("pexels API error: status %d", resp.StatusCode)
}
var result struct {
Photos []struct {
Src struct {
Medium string `json:"medium"`
} `json:"src"`
} `json:"photos"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return defaultPlaceholder, fmt.Errorf("decode response: %w", err)
}
if len(result.Photos) == 0 || result.Photos[0].Src.Medium == "" {
return defaultPlaceholder, nil
}
return result.Photos[0].Src.Medium, nil
}

View File

@@ -0,0 +1,62 @@
package recipe
import (
"encoding/json"
"time"
)
// Recipe is a recipe record in the database.
type Recipe struct {
ID string `json:"id"`
Source string `json:"source"` // spoonacular | ai | user
SpoonacularID *int `json:"spoonacular_id"`
Title string `json:"title"`
TitleRu *string `json:"title_ru"`
Description *string `json:"description"`
DescriptionRu *string `json:"description_ru"`
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"`
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 json.RawMessage `json:"ingredients"` // []RecipeIngredient
Steps json.RawMessage `json:"steps"` // []RecipeStep
Tags json.RawMessage `json:"tags"` // []string
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"`
}
// RecipeIngredient is a single ingredient in a recipe's JSONB array.
type RecipeIngredient struct {
SpoonacularID *int `json:"spoonacular_id"`
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
NameRu *string `json:"name_ru"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
UnitRu *string `json:"unit_ru"`
Optional bool `json:"optional"`
}
// RecipeStep is a single step in a recipe's JSONB array.
type RecipeStep struct {
Number int `json:"number"`
Description string `json:"description"`
DescriptionRu *string `json:"description_ru"`
TimerSeconds *int `json:"timer_seconds"`
ImageURL *string `json:"image_url"`
}

View File

@@ -0,0 +1,181 @@
package recipe
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for recipes.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates a recipe.
// 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, title_ru, description_ru,
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, $19, $20)
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, title_ru, description_ru,
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.TitleRu, recipe.DescriptionRu,
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)
}
// Count returns the total number of recipes.
func (r *Repository) Count(ctx context.Context) (int, error) {
var n int
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM recipes`).Scan(&n); err != nil {
return 0, fmt.Errorf("count recipes: %w", err)
}
return n, nil
}
// ListUntranslated returns recipes without a Russian title, ordered by review_count DESC.
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) {
query := `
SELECT id, source, spoonacular_id,
title, description, title_ru, description_ru,
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 title_ru IS NULL AND source = 'spoonacular'
ORDER BY review_count DESC
LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("list untranslated recipes: %w", err)
}
defer rows.Close()
return collectRecipes(rows)
}
// UpdateTranslation saves the Russian title, description, and step translations.
func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error {
query := `
UPDATE recipes SET
title_ru = $2,
description_ru = $3,
steps = $4,
updated_at = now()
WHERE id = $1`
if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil {
return fmt.Errorf("update recipe translation %s: %w", id, err)
}
return nil
}
// --- 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.TitleRu, &rec.DescriptionRu,
&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,
)
if err != nil {
return nil, err
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
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.TitleRu, &rec.DescriptionRu,
&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()
}
// GetByID returns a recipe by UUID.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
query := `
SELECT id, source, spoonacular_id,
title, description, title_ru, description_ru,
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 id = $1`
row := r.pool.QueryRow(ctx, query, id)
rec, err := scanRecipe(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
}

View File

@@ -0,0 +1,311 @@
//go:build integration
package recipe
import (
"context"
"encoding/json"
"testing"
"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)
ctx := context.Background()
got, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Error("expected nil for non-existent ID")
}
}
func TestRecipeRepository_ListUntranslated_Pagination(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)
}
}
untranslated, err := repo.ListUntranslated(ctx, 3, 0)
if err != nil {
t.Fatalf("list untranslated: %v", err)
}
if len(untranslated) != 3 {
t.Errorf("expected 3 results with limit=3, got %d", len(untranslated))
}
}
func TestRecipeRepository_UpdateTranslation(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":"Heat oil","description_ru":"Разогрейте масло"}]`)
if err := repo.UpdateTranslation(ctx, saved.ID, &titleRu, &descRu, stepsRu); err != nil {
t.Fatalf("update translation: %v", err)
}
got, err := repo.GetByID(ctx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.TitleRu == nil || *got.TitleRu != titleRu {
t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu)
}
if got.DescriptionRu == nil || *got.DescriptionRu != descRu {
t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu)
}
var steps []RecipeStep
if err := json.Unmarshal(got.Steps, &steps); err != nil {
t.Fatalf("unmarshal steps: %v", err)
}
if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" {
t.Errorf("expected description_ru in steps, got %v", steps)
}
}
func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
diff := "easy"
// Insert untranslated
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 translated
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.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil {
t.Fatalf("update translation: %v", err)
}
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
if err != nil {
t.Fatalf("list untranslated: %v", err)
}
for _, r := range untranslated {
if r.Title == "Translated Recipe" {
t.Error("translated recipe should not appear in ListUntranslated")
}
}
if len(untranslated) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
}
}
func TestRecipeRepository_GIN_Tags(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 70001
diff := "easy"
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Veggie Bowl",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`["vegetarian","gluten-free"]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
// GIN index query: tags @> '["vegetarian"]'
var count int
row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id)
if err := row.Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("expected 1 vegetarian recipe, got %d", count)
}
}

View File

@@ -0,0 +1,138 @@
package recommendation
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"sync"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/user"
)
// PhotoSearcher can search for a photo by text query.
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error)
}
// UserLoader can load a user profile by ID.
type UserLoader interface {
GetByID(ctx context.Context, id string) (*user.User, error)
}
// userPreferences is the shape of user.Preferences JSONB.
type userPreferences struct {
Cuisines []string `json:"cuisines"`
Restrictions []string `json:"restrictions"`
}
// Handler handles GET /recommendations.
type Handler struct {
gemini *gemini.Client
pexels PhotoSearcher
userLoader UserLoader
}
// NewHandler creates a new Handler.
func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader) *Handler {
return &Handler{
gemini: geminiClient,
pexels: pexels,
userLoader: userLoader,
}
}
// GetRecommendations handles GET /recommendations?count=5.
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
count := 5
if s := r.URL.Query().Get("count"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 20 {
count = n
}
}
u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil {
slog.Error("load user for recommendations", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile")
return
}
req := buildRecipeRequest(u, count)
recipes, err := h.gemini.GenerateRecipes(r.Context(), req)
if err != nil {
slog.Error("generate recipes", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again")
return
}
// Fetch Pexels photos in parallel — each goroutine owns a distinct index.
var wg sync.WaitGroup
for i := range recipes {
wg.Add(1)
go func(i int) {
defer wg.Done()
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
if err != nil {
slog.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
}
recipes[i].ImageURL = imageURL
}(i)
}
wg.Wait()
writeJSON(w, http.StatusOK, recipes)
}
func buildRecipeRequest(u *user.User, count int) gemini.RecipeRequest {
req := gemini.RecipeRequest{
Count: count,
DailyCalories: 2000, // sensible default
}
if u.Goal != nil {
req.UserGoal = *u.Goal
}
if u.DailyCalories != nil && *u.DailyCalories > 0 {
req.DailyCalories = *u.DailyCalories
}
if len(u.Preferences) > 0 {
var prefs userPreferences
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
req.CuisinePrefs = prefs.Cuisines
req.Restrictions = prefs.Restrictions
}
}
return req
}
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)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
slog.Error("write error response", "err", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write JSON response", "err", err)
}
}

View File

@@ -0,0 +1,135 @@
package savedrecipe
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
"github.com/go-chi/chi/v5"
)
const maxBodySize = 1 << 20 // 1 MB
// Handler handles HTTP requests for saved recipes.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// Save handles POST /saved-recipes.
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req SaveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeErrorJSON(w, http.StatusBadRequest, "title is required")
return
}
rec, err := h.repo.Save(r.Context(), userID, req)
if err != nil {
slog.Error("save recipe", "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe")
return
}
writeJSON(w, http.StatusCreated, rec)
}
// List handles GET /saved-recipes.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
recipes, err := h.repo.List(r.Context(), userID)
if err != nil {
slog.Error("list saved recipes", "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
return
}
if recipes == nil {
recipes = []*SavedRecipe{}
}
writeJSON(w, http.StatusOK, recipes)
}
// GetByID handles GET /saved-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(), userID, id)
if err != nil {
slog.Error("get saved recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe")
return
}
if rec == nil {
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
return
}
writeJSON(w, http.StatusOK, rec)
}
// Delete handles DELETE /saved-recipes/{id}.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), userID, id); err != nil {
if errors.Is(err, ErrNotFound) {
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
return
}
slog.Error("delete saved recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe")
return
}
w.WriteHeader(http.StatusNoContent)
}
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)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
slog.Error("write error response", "err", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write JSON response", "err", err)
}
}

View File

@@ -0,0 +1,43 @@
package savedrecipe
import (
"encoding/json"
"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"`
}
// SaveRequest is the body for POST /saved-recipes.
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"`
}

View File

@@ -0,0 +1,173 @@
package savedrecipe
import (
"context"
"encoding/json"
"errors"
"fmt"
"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.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Save persists a recipe for userID and returns the stored record.
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`
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)
source := req.Source
if source == "" {
source = "ai"
}
ingredients := defaultJSONArray(req.Ingredients)
steps := defaultJSONArray(req.Steps)
tags := defaultJSONArray(req.Tags)
row := r.pool.QueryRow(ctx, query,
userID, req.Title, description, cuisine, difficulty,
prepTime, cookTime, servings, imageURL,
ingredients, steps, tags, req.Nutrition, source,
)
return scanRow(row)
}
// List returns all saved recipes for userID ordered by saved_at DESC.
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
const query = `
SELECT id, user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source, saved_at
FROM saved_recipes
WHERE user_id = $1
ORDER BY saved_at DESC`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("list saved recipes: %w", err)
}
defer rows.Close()
var result []*SavedRecipe
for rows.Next() {
rec, err := scanRows(rows)
if err != nil {
return nil, fmt.Errorf("scan saved recipe: %w", err)
}
result = append(result, rec)
}
return result, rows.Err()
}
// GetByID returns the saved recipe with id for userID, or nil if not found.
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
const query = `
SELECT id, user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source, saved_at
FROM saved_recipes
WHERE id = $1 AND user_id = $2`
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
}
// Delete removes the saved recipe with id for userID.
// Returns ErrNotFound if the record does not exist.
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,
)
if err != nil {
return fmt.Errorf("delete saved recipe: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// --- helpers ---
type scannable interface {
Scan(dest ...any) error
}
func scanRow(s scannable) (*SavedRecipe, error) {
var rec SavedRecipe
var ingredients, steps, tags, nutrition []byte
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,
)
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
}
// scanRows wraps pgx.Rows to satisfy the scannable interface.
func scanRows(rows pgx.Rows) (*SavedRecipe, error) {
return scanRow(rows)
}
func nullableStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func nullableInt(n int) *int {
if n <= 0 {
return nil
}
return &n
}
func defaultJSONArray(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return json.RawMessage(`[]`)
}
return raw
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -15,6 +17,8 @@ func NewRouter(
pool *pgxpool.Pool,
authHandler *auth.Handler,
userHandler *user.Handler,
recommendationHandler *recommendation.Handler,
savedRecipeHandler *savedrecipe.Handler,
authMiddleware func(http.Handler) http.Handler,
allowedOrigins []string,
) *chi.Mux {
@@ -37,8 +41,18 @@ func NewRouter(
// Protected
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/profile", userHandler.Get)
r.Put("/profile", userHandler.Update)
r.Get("/recommendations", recommendationHandler.GetRecommendations)
r.Route("/saved-recipes", func(r chi.Router) {
r.Post("/", savedRecipeHandler.Save)
r.Get("/", savedRecipeHandler.List)
r.Get("/{id}", savedRecipeHandler.GetByID)
r.Delete("/{id}", savedRecipeHandler.Delete)
})
})
return r