# 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 1. `Product` MongoDB schema and full CRUD API 2. Full-text search with filters 3. Barcode lookup via Open Food Facts 4. Bulk import (CSV/JSON) 5. Product library web UI (search, add, edit) 6. LLM provider interface (`ILlmProvider`) with no-op implementation 7. "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 `deletedAt` rather than removing rows, so historical recipes/pantry/grocery references stay resolvable. --- ## Data Model ### Product Schema ```typescript // 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 ```javascript // 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/:id` is a soft delete; the product is hidden from listings but remains resolvable by id for historical references in recipes, pantry, and grocery. - `POST /products` rejects payloads whose `barcode` collides with an existing non-deleted product in the household (409 `ConflictError`). ### 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 ```typescript interface PaginatedResponse { 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`; `nutrition` macros `>= 0`; `barcode` matches `/^\d{8,14}$/` - `UpdateProductSchema` — `CreateProductSchema.partial()` - `ProductQuerySchema` — validates query params; `limit` clamped to `[1, 100]`, default 20 - `ImportProductsSchema` — array of `CreateProductSchema` (for JSON import) - All schemas imported from `'zod/v4'`; enums use `z.enum(Object.values(...))`. **Status**: DONE (types, enums, validation schemas all exist with tests) ### 5.2 — Mongoose Schema & Repository - `packages/api/src/modules/products/schemas/product.schema.ts` — Mongoose schema with `timestamps: true`, `deletedAt` index, partial unique index on `(householdId, barcode)`. - `ProductRepository` with: - `findByHousehold(householdId, query)` — text search, filters, cursor pagination, excludes soft-deleted - `findById(id, householdId)` — also returns soft-deleted (for historical resolution) - `findByBarcode(householdId, barcode)` — excludes soft-deleted - `findByIds(householdId, ids[])` — batch fetch for recipe/pantry resolution - `create(data)` - `update(id, householdId, data)` - `softDelete(id, householdId)` — sets `deletedAt` - `bulkCreate(householdId, items[])` — uses `insertMany` with `ordered: false` - All read queries use `.lean().exec()`. **Status**: DONE (repository exists with tests) ### 5.3 — Products Service - `ProductsService` with business logic: - Dedup check on create (same name + brand within household) - Barcode collision check (409 ConflictError) - Soft-delete with `deletedAt` timestamp - Coordinate barcode lookup (call BarcodeService) - Coordinate import (validate + bulkCreate) - Smart-add (call LLM provider) **Status**: NOT STARTED ### 5.4 — Products Routes - Route plugin registered via `fp()`: - `GET /products` — list/search (paginated) - `GET /products/:id` — get single - `POST /products` — create - `PATCH /products/:id` — update - `DELETE /products/:id` — soft-delete (admin) - `GET /products/barcode/:code` — barcode lookup - `POST /products/import` — bulk import - `POST /products/smart-add` — LLM placeholder **Status**: NOT STARTED ### 5.5 — 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 `Product` shape: - `product_name` → `name`; `brands` (first) → `brand`; `categories_tags` → derived `ProductCategory` - Nutrition normalized to per-serving (`g` or `ml`) using `serving_size` / `serving_quantity` from 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 `undici` with a 5s timeout and a configurable User-Agent (`MeshiTrack/ (+self-hosted)`) **Status**: NOT STARTED ### 5.6 — LLM Provider Interface - `packages/api/src/modules/llm/interfaces/llm-provider.interface.ts`: ```typescript export interface ILlmProvider { extractNutrition(input: { text?: string; image?: Buffer; }): Promise; parseRecipe(text: string): Promise; parseRecipeFromUrl(url: string): Promise; parseReceipt(image: Buffer): Promise; suggestMealPlan(context: MealPlanContext): Promise; parseNaturalLanguage(text: string): Promise; } export const LLM_PROVIDER = Symbol('LLM_PROVIDER'); ``` - `NoOpLlmProvider`: implements interface, returns `null` for all methods, logs a warning - `LlmModule`: provides `LLM_PROVIDER` via factory, selectable by env var `LLM_PROVIDER_TYPE` **Status**: DONE (interface + NoOp provider exist with tests) ### 5.7 — Smart Add Endpoint - `POST /products/smart-add` accepts `{ 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' }` **Status**: NOT STARTED (blocked on 5.3/5.4) ### 5.8 — Import Endpoint - `POST /products/import` accepts multipart CSV or JSON file (max 5 MB, 5000 rows) - Validate each row against `CreateProductSchema`; reject rows with imperial `servingUnit` values with a clear error message - De-dup by `(householdId, barcode)` and `(householdId, name, brand)`; existing matches are reported as `skipped` - 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, tags` - `tags` is a `;`-separated list - `servingUnit` ∈ `{g, ml, piece, slice}` **Status**: NOT STARTED (blocked on 5.3/5.4) ### 5.9 — Web: API Client Service - `packages/web/src/services/products.ts` — fetch functions for all product endpoints - Unit tests in `packages/web/src/services/__tests__/products.test.ts` **Status**: NOT STARTED ### 5.10 — Web UI: Product Library - `/products` page (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; `servingUnit` select limited to `g | ml | piece | slice` - Nutrition input section with per-serving values - Optional `densityGPerMl` field (only relevant for liquids/pastes) - Barcode field with "Lookup" button (calls `/products/barcode/:code`) - "Smart Add" tab (text input or image upload) - Import dialog: file upload with preview, row count, and error display - Component tests for page and interactive components **Status**: NOT STARTED ### 5.11 — CI Verification - `npm run build` passes - `npm run test:cov` passes (100% lines/functions/statements, 90% branches) - `npm run lint` passes --- ## Acceptance Criteria - [ ] Can create, read, update, soft-delete products via API - [ ] Soft-deleted products remain resolvable by id but excluded from listings - [ ] `ServingUnit` is restricted to `g | 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 - [ ] Web UI has component tests for all pages and interactive components - [ ] `ILlmProvider` interface 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) - [ ] `npm run build`, `npm run test:cov`, and `npm run lint` all pass --- ## Estimated Effort Medium. Straightforward CRUD with search; barcode integration adds some complexity.