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

@@ -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