288 lines
12 KiB
Markdown
288 lines
12 KiB
Markdown
# 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<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`; `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(...))`.
|
|
|
|
### 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()`.
|
|
|
|
### 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 `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/<version> (+self-hosted)`)
|
|
|
|
### 5.4 — LLM Provider Interface
|
|
|
|
- `packages/api/src/modules/llm/interfaces/llm-provider.interface.ts`:
|
|
|
|
```typescript
|
|
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, returns `null` for all methods, logs a warning
|
|
- `LlmModule`: provides `LLM_PROVIDER` via factory, selectable by env var `LLM_PROVIDER_TYPE`
|
|
|
|
### 5.5 — 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' }`
|
|
|
|
### 5.6 — 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}`
|
|
|
|
### 5.7 — 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
|
|
|
|
---
|
|
|
|
## 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
|
|
- [ ] `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)
|
|
|
|
---
|
|
|
|
## Estimated Effort
|
|
|
|
Medium. Straightforward CRUD with search; barcode integration adds some complexity.
|