feat: implement Iteration 2 — product management

Backend:
- migrations/005: add pg_trgm extension + search indexes on ingredient_mappings
- migrations/006: products table with computed expires_at column
- ingredient: add Search method (aliases + ILIKE + trgm) + HTTP handler
- product: full package — model, repository (CRUD + BatchCreate + ListForPrompt), handler
- gemini: add AvailableProducts field to RecipeRequest, include in prompt
- recommendation: add ProductLister interface, load user products for personalised prompts
- server/main: wire ingredient and product handlers with new routes

Flutter:
- models: Product, IngredientMapping with json_serializable
- ProductService: getProducts, createProduct, updateProduct, deleteProduct, searchIngredients
- ProductsNotifier: create/update/delete with optimistic delete
- ProductsScreen: expiring-soon section, normal section, swipe-to-delete, edit bottom sheet
- AddProductScreen: name field with 300ms debounce autocomplete, qty/unit/days fields
- app_router: /products/add route + Badge on Products nav tab showing expiring count
- MainShell converted to ConsumerWidget for badge reactivity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-21 23:22:30 +02:00
parent 0dbda0cd57
commit b9b9e9fe11
20 changed files with 1585 additions and 32 deletions

View File

@@ -14,8 +14,10 @@ import (
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/database"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/server"
@@ -91,8 +93,16 @@ func run() error {
geminiClient := gemini.NewClient(cfg.GeminiAPIKey)
pexelsClient := pexels.NewClient(cfg.PexelsAPIKey)
// Ingredient domain
ingredientRepo := ingredient.NewRepository(pool)
ingredientHandler := ingredient.NewHandler(ingredientRepo)
// Product domain
productRepo := product.NewRepository(pool)
productHandler := product.NewHandler(productRepo)
// Recommendation domain
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo)
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
// Saved recipes domain
savedRecipeRepo := savedrecipe.NewRepository(pool)
@@ -105,6 +115,8 @@ func run() error {
userHandler,
recommendationHandler,
savedRecipeHandler,
ingredientHandler,
productHandler,
authMW,
cfg.AllowedOrigins,
)

View File

@@ -14,11 +14,12 @@ type RecipeGenerator interface {
// 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
UserGoal string // "weight_loss" | "maintain" | "gain"
DailyCalories int
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
CuisinePrefs []string // e.g. ["russian", "asian"]
Count int
AvailableProducts []string // human-readable list of products in user's pantry
}
// Recipe is a recipe returned by Gemini.
@@ -134,6 +135,13 @@ func buildRecipePrompt(req RecipeRequest) string {
perMealCalories = 600
}
productsSection := ""
if len(req.AvailableProducts) > 0 {
productsSection = "\nДоступные продукты (приоритет — скоро истекают ⚠):\n" +
strings.Join(req.AvailableProducts, "\n") +
"\nПредпочтительно использовать эти продукты в рецептах.\n"
}
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке.
Профиль пользователя:
@@ -141,7 +149,7 @@ func buildRecipePrompt(req RecipeRequest) string {
- Дневная норма калорий: %d ккал
- Ограничения: %s
- Предпочтения: %s
%s
Требования к каждому рецепту:
- Калорийность на порцию: не более %d ккал
- Время приготовления: до 60 минут
@@ -165,7 +173,7 @@ func buildRecipePrompt(req RecipeRequest) string {
"nutrition_per_serving": {
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18
}
}]`, count, goal, req.DailyCalories, restrictions, cuisines, perMealCalories)
}]`, count, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories)
}
func parseRecipesJSON(text string) ([]Recipe, error) {

View File

@@ -0,0 +1,51 @@
package ingredient
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
)
// Handler handles ingredient HTTP requests.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// Search handles GET /ingredients/search?q=&limit=10.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("[]"))
return
}
limit := 10
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 50 {
limit = n
}
}
mappings, err := h.repo.Search(r.Context(), q, limit)
if err != nil {
slog.Error("search ingredients", "q", q, "err", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"search failed"}`))
return
}
if mappings == nil {
mappings = []*IngredientMapping{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mappings)
}

View File

@@ -94,6 +94,32 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
return m, err
}
// Search finds ingredient mappings matching the query string.
// Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity.
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
if limit <= 0 {
limit = 10
}
q := `
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 aliases @> to_jsonb(lower($1)::text)
OR canonical_name_ru ILIKE '%' || $1 || '%'
OR similarity(canonical_name_ru, $1) > 0.3
ORDER BY similarity(canonical_name_ru, $1) DESC
LIMIT $2`
rows, err := r.pool.Query(ctx, q, query, limit)
if err != nil {
return nil, fmt.Errorf("search ingredient_mappings: %w", err)
}
defer rows.Close()
return collectMappings(rows)
}
// Count returns the total number of ingredient mappings.
func (r *Repository) Count(ctx context.Context) (int, error) {
var n int

View File

@@ -0,0 +1,137 @@
package product
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler handles /products HTTP requests.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// List handles GET /products.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
products, err := h.repo.List(r.Context(), userID)
if err != nil {
slog.Error("list products", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to list products")
return
}
if products == nil {
products = []*Product{}
}
writeJSON(w, http.StatusOK, products)
}
// Create handles POST /products.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeErrorJSON(w, http.StatusBadRequest, "name is required")
return
}
p, err := h.repo.Create(r.Context(), userID, req)
if err != nil {
slog.Error("create product", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to create product")
return
}
writeJSON(w, http.StatusCreated, p)
}
// BatchCreate handles POST /products/batch.
func (h *Handler) BatchCreate(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
var items []CreateRequest
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if len(items) == 0 {
writeJSON(w, http.StatusCreated, []*Product{})
return
}
products, err := h.repo.BatchCreate(r.Context(), userID, items)
if err != nil {
slog.Error("batch create products", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to create products")
return
}
writeJSON(w, http.StatusCreated, products)
}
// Update handles PUT /products/{id}.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
id := chi.URLParam(r, "id")
var req UpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
p, err := h.repo.Update(r.Context(), id, userID, req)
if errors.Is(err, ErrNotFound) {
writeErrorJSON(w, http.StatusNotFound, "product not found")
return
}
if err != nil {
slog.Error("update product", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to update product")
return
}
writeJSON(w, http.StatusOK, p)
}
// Delete handles DELETE /products/{id}.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
if errors.Is(err, ErrNotFound) {
writeErrorJSON(w, http.StatusNotFound, "product not found")
return
}
slog.Error("delete product", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete product")
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)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,39 @@
package product
import "time"
// Product is a user's food item in their pantry.
type Product struct {
ID string `json:"id"`
UserID string `json:"user_id"`
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
AddedAt time.Time `json:"added_at"`
ExpiresAt time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
ExpiringSoon bool `json:"expiring_soon"`
}
// CreateRequest is the body for POST /products.
type CreateRequest struct {
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
}
// UpdateRequest is the body for PUT /products/{id}.
// All fields are optional (nil = keep existing value).
type UpdateRequest struct {
Name *string `json:"name"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit"`
Category *string `json:"category"`
StorageDays *int `json:"storage_days"`
}

View File

@@ -0,0 +1,188 @@
package product
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a product is not found or does not belong to the user.
var ErrNotFound = errors.New("product not found")
// Repository handles product persistence.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
const selectCols = `id, user_id, mapping_id, name, quantity, unit, category, storage_days, added_at, expires_at`
// List returns all products for a user, sorted by expires_at ASC.
func (r *Repository) List(ctx context.Context, userID string) ([]*Product, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+selectCols+`
FROM products
WHERE user_id = $1
ORDER BY expires_at ASC`, userID)
if err != nil {
return nil, fmt.Errorf("list products: %w", err)
}
defer rows.Close()
return collectProducts(rows)
}
// Create inserts a new product and returns the created record.
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) {
storageDays := req.StorageDays
if storageDays <= 0 {
storageDays = 7
}
unit := req.Unit
if unit == "" {
unit = "pcs"
}
qty := req.Quantity
if qty <= 0 {
qty = 1
}
row := r.pool.QueryRow(ctx, `
INSERT INTO products (user_id, mapping_id, name, quantity, unit, category, storage_days)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+selectCols,
userID, req.MappingID, req.Name, qty, unit, req.Category, storageDays,
)
return scanProduct(row)
}
// BatchCreate inserts multiple products sequentially and returns all created records.
func (r *Repository) BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) {
var result []*Product
for _, req := range items {
p, err := r.Create(ctx, userID, req)
if err != nil {
return nil, fmt.Errorf("batch create product %q: %w", req.Name, err)
}
result = append(result, p)
}
return result, nil
}
// Update modifies an existing product. Only non-nil fields are changed.
// Returns ErrNotFound if the product does not exist or belongs to a different user.
func (r *Repository) Update(ctx context.Context, id, userID string, req UpdateRequest) (*Product, error) {
row := r.pool.QueryRow(ctx, `
UPDATE products SET
name = COALESCE($3, name),
quantity = COALESCE($4, quantity),
unit = COALESCE($5, unit),
category = COALESCE($6, category),
storage_days = COALESCE($7, storage_days)
WHERE id = $1 AND user_id = $2
RETURNING `+selectCols,
id, userID, req.Name, req.Quantity, req.Unit, req.Category, req.StorageDays,
)
p, err := scanProduct(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return p, err
}
// Delete removes a product. Returns ErrNotFound if it does not exist or belongs to a different user.
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM products WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete product: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListForPrompt returns a human-readable list of user's products for the AI prompt.
// Expiring soon items are marked with ⚠.
func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT name, quantity, unit, expires_at
FROM products
WHERE user_id = $1
ORDER BY expires_at ASC`, userID)
if err != nil {
return nil, fmt.Errorf("list products for prompt: %w", err)
}
defer rows.Close()
var lines []string
now := time.Now()
for rows.Next() {
var name, unit string
var qty float64
var expiresAt time.Time
if err := rows.Scan(&name, &qty, &unit, &expiresAt); err != nil {
return nil, fmt.Errorf("scan product for prompt: %w", err)
}
daysLeft := int(expiresAt.Sub(now).Hours() / 24)
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
switch {
case daysLeft <= 0:
line += " (истекает сегодня ⚠)"
case daysLeft == 1:
line += " (истекает завтра ⚠)"
case daysLeft <= 3:
line += fmt.Sprintf(" (истекает через %d дня ⚠)", daysLeft)
}
lines = append(lines, line)
}
return lines, rows.Err()
}
// --- helpers ---
func scanProduct(row pgx.Row) (*Product, error) {
var p Product
err := row.Scan(
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
)
if err != nil {
return nil, err
}
computeDaysLeft(&p)
return &p, nil
}
func collectProducts(rows pgx.Rows) ([]*Product, error) {
var result []*Product
for rows.Next() {
var p Product
if err := rows.Scan(
&p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit,
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("scan product: %w", err)
}
computeDaysLeft(&p)
result = append(result, &p)
}
return result, rows.Err()
}
func computeDaysLeft(p *Product) {
d := int(time.Until(p.ExpiresAt).Hours() / 24)
if d < 0 {
d = 0
}
p.DaysLeft = d
p.ExpiringSoon = d <= 3
}

View File

@@ -23,6 +23,11 @@ type UserLoader interface {
GetByID(ctx context.Context, id string) (*user.User, error)
}
// ProductLister returns a human-readable list of user's products for the AI prompt.
type ProductLister interface {
ListForPrompt(ctx context.Context, userID string) ([]string, error)
}
// userPreferences is the shape of user.Preferences JSONB.
type userPreferences struct {
Cuisines []string `json:"cuisines"`
@@ -31,17 +36,19 @@ type userPreferences struct {
// Handler handles GET /recommendations.
type Handler struct {
gemini *gemini.Client
pexels PhotoSearcher
userLoader UserLoader
gemini *gemini.Client
pexels PhotoSearcher
userLoader UserLoader
productLister ProductLister
}
// NewHandler creates a new Handler.
func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader) *Handler {
func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler {
return &Handler{
gemini: geminiClient,
pexels: pexels,
userLoader: userLoader,
gemini: geminiClient,
pexels: pexels,
userLoader: userLoader,
productLister: productLister,
}
}
@@ -69,6 +76,13 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
req := buildRecipeRequest(u, count)
// Attach available products to personalise the prompt.
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
req.AvailableProducts = products
} else {
slog.Warn("load products for recommendations", "user_id", userID, "err", err)
}
recipes, err := h.gemini.GenerateRecipes(r.Context(), req)
if err != nil {
slog.Error("generate recipes", "user_id", userID, "err", err)

View File

@@ -5,7 +5,9 @@ import (
"net/http"
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
@@ -19,6 +21,8 @@ func NewRouter(
userHandler *user.Handler,
recommendationHandler *recommendation.Handler,
savedRecipeHandler *savedrecipe.Handler,
ingredientHandler *ingredient.Handler,
productHandler *product.Handler,
authMiddleware func(http.Handler) http.Handler,
allowedOrigins []string,
) *chi.Mux {
@@ -38,6 +42,12 @@ func NewRouter(
r.Post("/logout", authHandler.Logout)
})
// Public search (still requires auth to prevent scraping)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/ingredients/search", ingredientHandler.Search)
})
// Protected
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
@@ -53,6 +63,14 @@ func NewRouter(
r.Get("/{id}", savedRecipeHandler.GetByID)
r.Delete("/{id}", savedRecipeHandler.Delete)
})
r.Route("/products", func(r chi.Router) {
r.Get("/", productHandler.List)
r.Post("/", productHandler.Create)
r.Post("/batch", productHandler.BatchCreate)
r.Put("/{id}", productHandler.Update)
r.Delete("/{id}", productHandler.Delete)
})
})
return r

View File

@@ -0,0 +1,12 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_ingredient_mappings_canonical_ru_trgm
ON ingredient_mappings USING GIN (canonical_name_ru gin_trgm_ops);
CREATE INDEX idx_ingredient_mappings_canonical_ru_fts
ON ingredient_mappings USING GIN (to_tsvector('russian', coalesce(canonical_name_ru, '')));
-- +goose Down
DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_trgm;
DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_fts;

View File

@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mapping_id UUID REFERENCES ingredient_mappings(id),
name TEXT NOT NULL,
quantity DECIMAL(10, 2) NOT NULL DEFAULT 1,
unit TEXT NOT NULL DEFAULT 'pcs',
category TEXT,
storage_days INT NOT NULL DEFAULT 7,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ GENERATED ALWAYS AS
(added_at + (storage_days || ' days')::INTERVAL) STORED
);
CREATE INDEX idx_products_user_id ON products(user_id);
CREATE INDEX idx_products_expires_at ON products(user_id, expires_at);
-- +goose Down
DROP TABLE products;