12 KiB
12 KiB
Phase 5 — Product Library
Goal: A searchable catalog of food products with nutrition data, reusable across the entire food tracking domain. Products are the atomic building blocks for recipes, pantry items, and shopping lists.
Depends on: Phase 0 (auth, households, shared types). Can reuse Store infrastructure from Phase 4.
Deliverables
ProductMongoDB schema and full CRUD API- Full-text search with filters
- Barcode lookup via Open Food Facts
- Bulk import (CSV/JSON)
- Product library web UI (search, add, edit)
- LLM provider interface (
ILlmProvider) with no-op implementation - "Smart Add" endpoint placeholder
Design Principles
- Metric-only storage: Products store nutrition relative to a metric serving (
g,ml) or a discrete unit (piece,slice). Imperial/volume cooking units (oz, cup, tbsp, tsp) are a recipe-input concern and are normalized to metric in Phase 6 before persistence. This keeps nutrition math density-free at the product level. - Per-household catalog: Every product is owned by exactly one household. There is no cross-household sharing in this phase; a global/public catalog can be added later via an explicit seed dataset.
- Soft delete: Deletes set
deletedAtrather than removing rows, so historical recipes/pantry/grocery references stay resolvable.
Data Model
Product Schema
// packages/shared/src/types/product.ts
export interface Product {
id: string;
householdId: string;
name: string;
brand?: string;
barcode?: string; // EAN-13 / UPC-A, digits only
category: ProductCategory;
servingSize: number; // quantity of one serving in `servingUnit`
servingUnit: ServingUnit; // metric or discrete only
densityGPerMl?: number; // optional, used by Phase 6 to convert volume cooking units
nutrition: NutritionInfo; // values are PER serving (size = servingSize servingUnit)
tags: string[];
imageUrl?: string;
source: ProductSource;
createdBy: string; // userId
createdAt: Date;
updatedAt: Date;
deletedAt?: Date; // soft delete
}
export interface NutritionInfo {
calories: number; // kcal per serving
protein: number; // grams
carbs: number; // grams
fat: number; // grams
fiber?: number; // grams
sugar?: number; // grams
sodium?: number; // mg
saturatedFat?: number; // grams
cholesterol?: number; // mg
}
export enum ProductCategory {
DAIRY = 'dairy',
MEAT = 'meat',
POULTRY = 'poultry',
SEAFOOD = 'seafood',
FRUITS = 'fruits',
VEGETABLES = 'vegetables',
GRAINS = 'grains',
LEGUMES = 'legumes',
NUTS_SEEDS = 'nuts_seeds',
OILS_FATS = 'oils_fats',
CONDIMENTS = 'condiments',
SPICES = 'spices',
BEVERAGES = 'beverages',
SNACKS = 'snacks',
FROZEN = 'frozen',
CANNED = 'canned',
BAKERY = 'bakery',
DELI = 'deli',
SUPPLEMENTS = 'supplements',
OTHER = 'other',
}
export enum ServingUnit {
GRAMS = 'g',
MILLILITERS = 'ml',
PIECES = 'piece',
SLICES = 'slice',
}
// Note: imperial/volume cooking units (oz, cup, tbsp, tsp) are intentionally
// excluded. Recipes may receive them as input in Phase 6 and convert to metric
// before persisting. See `phase-6-recipes.md` for the conversion rules.
export enum ProductSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
LLM = 'llm',
IMPORT = 'import',
}
MongoDB Indexes
// Text index for search
{ name: 'text', brand: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, deletedAt: 1, category: 1 }
{ householdId: 1, barcode: 1 } // partial index where barcode exists & deletedAt is null; unique within household
{ householdId: 1, name: 1, brand: 1 } // near-unique for dedup
All list/search queries filter deletedAt: { $exists: false } (or null).
API Endpoints
ProductsModule
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /products |
List/search products (paginated) | member |
| GET | /products/:id |
Get single product | member |
| POST | /products |
Create product | member |
| PATCH | /products/:id |
Update product | member |
| DELETE | /products/:id |
Soft-delete product | admin |
| GET | /products/barcode/:code |
Lookup by barcode (local → Open Food Facts) | member |
| POST | /products/import |
Bulk import from CSV/JSON | admin |
| POST | /products/smart-add |
LLM-powered add from text/image | member |
Notes:
DELETE /products/:idis a soft delete; the product is hidden from listings but remains resolvable by id for historical references in recipes, pantry, and grocery.POST /productsrejects payloads whosebarcodecollides with an existing non-deleted product in the household (409ConflictError).
Query Parameters for GET /products
?q=chicken # Full-text search (name, brand, tags)
&category=meat # Filter by category
&tags=organic,fresh # Filter by tags (AND)
&barcode=0123456789012 # Exact barcode match
&includeDeleted=false # Default false; admins can pass true
&cursor=abc123 # Cursor-based pagination (opaque)
&limit=20 # Page size (max 100)
&sort=name|-updatedAt # Sort field, prefix - for desc; default -updatedAt
Response Shape
interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null; // null = last page
hasMore: boolean;
total: number;
};
}
Tasks
5.1 — Shared Types & Validation
- Add all types above to
packages/shared/src/types/product.ts - Add enums to
packages/shared/src/enums/product.enums.ts(ProductCategory,ServingUnit,ProductSource) - Create Zod schemas in
packages/shared/src/validation/product.validation.ts:CreateProductSchema— validates create payload;servingSize > 0;nutritionmacros>= 0;barcodematches/^\d{8,14}$/UpdateProductSchema—CreateProductSchema.partial()ProductQuerySchema— validates query params;limitclamped to[1, 100], default 20ImportProductsSchema— array ofCreateProductSchema(for JSON import)
- All schemas imported from
'zod/v4'; enums usez.enum(Object.values(...)).
5.2 — Mongoose Schema & Repository
packages/api/src/modules/products/schemas/product.schema.ts— Mongoose schema withtimestamps: true,deletedAtindex, partial unique index on(householdId, barcode).ProductRepositorywith:findByHousehold(householdId, query)— text search, filters, cursor pagination, excludes soft-deletedfindById(id, householdId)— also returns soft-deleted (for historical resolution)findByBarcode(householdId, barcode)— excludes soft-deletedfindByIds(householdId, ids[])— batch fetch for recipe/pantry resolutioncreate(data)update(id, householdId, data)softDelete(id, householdId)— setsdeletedAtbulkCreate(householdId, items[])— usesinsertManywithordered: false
- All read queries use
.lean().exec().
5.3 — Barcode Lookup Service
BarcodeService:- First check local DB for matching barcode (per household)
- If not found, call Open Food Facts API:
https://world.openfoodfacts.org/api/v2/product/{barcode} - Map OFF response to
Productshape:product_name→name;brands(first) →brand;categories_tags→ derivedProductCategory- Nutrition normalized to per-serving (
gorml) usingserving_size/serving_quantityfrom OFF; fall back to per-100g if absent - Drop fields with no usable value (do not fabricate zeroes)
- Cache result in local DB with
source: 'barcode_lookup', owned by the requesting household - Failures (network, 404, malformed): return
{ found: false }; do not throw - Outbound HTTP via
undiciwith a 5s timeout and a configurable User-Agent (MeshiTrack/<version> (+self-hosted))
5.4 — LLM Provider Interface
packages/api/src/modules/llm/interfaces/llm-provider.interface.ts:
export interface ILlmProvider {
extractNutrition(input: {
text?: string;
image?: Buffer;
}): Promise<NutritionExtractionResult | null>;
parseRecipe(text: string): Promise<ParsedRecipe | null>;
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
}
export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
NoOpLlmProvider: implements interface, returnsnullfor all methods, logs a warningLlmModule: providesLLM_PROVIDERvia factory, selectable by env varLLM_PROVIDER_TYPE
5.5 — Smart Add Endpoint
POST /products/smart-addaccepts{ text?: string, image?: file }- Calls
ILlmProvider.extractNutrition() - If LLM returns data, pre-fill a product and return to client for review (not auto-saved)
- If LLM unavailable (
NoOpLlmProvider), return{ available: false, message: 'LLM not configured' }
5.6 — Import Endpoint
POST /products/importaccepts multipart CSV or JSON file (max 5 MB, 5000 rows)- Validate each row against
CreateProductSchema; reject rows with imperialservingUnitvalues with a clear error message - De-dup by
(householdId, barcode)and(householdId, name, brand); existing matches are reported asskipped - Return summary:
{ imported: N, skipped: M, errors: [{ row, message }] } - CSV column mapping:
name, brand, barcode, category, servingSize, servingUnit, densityGPerMl, calories, protein, carbs, fat, fiber, sugar, sodium, saturatedFat, cholesterol, tagstagsis a;-separated listservingUnit∈{g, ml, piece, slice}
5.7 — Web UI: Product Library
/productspage (Server Component for initial fetch; client island for filters):- Search bar with debounced full-text search (300ms)
- Category filter dropdown
- Tag filter chips
- Product grid/list view (toggle, persisted in
localStorage) - Each product card shows: name, brand, category icon, calories per serving, serving (
100 g,250 ml,1 piece)
- Add/Edit product modal:
- Form fields for all product properties;
servingUnitselect limited tog | ml | piece | slice - Nutrition input section with per-serving values
- Optional
densityGPerMlfield (only relevant for liquids/pastes) - Barcode field with "Lookup" button (calls
/products/barcode/:code) - "Smart Add" tab (text input or image upload)
- Form fields for all product properties;
- Import dialog: file upload with preview, row count, and error display
Acceptance Criteria
- Can create, read, update, soft-delete products via API
- Soft-deleted products remain resolvable by id but excluded from listings
ServingUnitis restricted tog | ml | piece | slice; imperial values are rejected at validation- Full-text search returns relevant results across name, brand, tags
- Barcode lookup fetches from Open Food Facts when not in local DB and caches the result
- Barcode collisions within a household return 409
- Bulk import processes a CSV with 100+ products and reports per-row errors
- Web UI allows searching, filtering, adding, and editing products
ILlmProviderinterface is defined and injectable- Smart Add endpoint returns graceful
{ available: false }with the NoOp provider - All product queries are scoped to
householdId - Unit + integration tests meet coverage targets (100% lines/functions/statements, 90% branches)
Estimated Effort
Medium. Straightforward CRUD with search; barcode integration adds some complexity.