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:
@@ -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) {
|
||||
|
||||
81
backend/internal/gemini/client.go
Normal file
81
backend/internal/gemini/client.go
Normal 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
|
||||
}
|
||||
186
backend/internal/gemini/recipe.go
Normal file
186
backend/internal/gemini/recipe.go
Normal 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
|
||||
}
|
||||
28
backend/internal/ingredient/model.go
Normal file
28
backend/internal/ingredient/model.go
Normal 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"`
|
||||
}
|
||||
185
backend/internal/ingredient/repository.go
Normal file
185
backend/internal/ingredient/repository.go
Normal 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()
|
||||
}
|
||||
250
backend/internal/ingredient/repository_integration_test.go
Normal file
250
backend/internal/ingredient/repository_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
77
backend/internal/pexels/client.go
Normal file
77
backend/internal/pexels/client.go
Normal 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
|
||||
}
|
||||
62
backend/internal/recipe/model.go
Normal file
62
backend/internal/recipe/model.go
Normal 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"`
|
||||
}
|
||||
181
backend/internal/recipe/repository.go
Normal file
181
backend/internal/recipe/repository.go
Normal 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
|
||||
}
|
||||
311
backend/internal/recipe/repository_integration_test.go
Normal file
311
backend/internal/recipe/repository_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
138
backend/internal/recommendation/handler.go
Normal file
138
backend/internal/recommendation/handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
135
backend/internal/savedrecipe/handler.go
Normal file
135
backend/internal/savedrecipe/handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
43
backend/internal/savedrecipe/model.go
Normal file
43
backend/internal/savedrecipe/model.go
Normal 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"`
|
||||
}
|
||||
173
backend/internal/savedrecipe/repository.go
Normal file
173
backend/internal/savedrecipe/repository.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user