Phase 5 cleanup

This commit is contained in:
Aerilyn Weber 2026-04-26 18:44:59 +09:00
parent 5536acd67d
commit 76a516a417
136 changed files with 6322 additions and 1985 deletions

View file

@ -16,6 +16,12 @@
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
@ -29,18 +35,19 @@ export interface Product {
householdId: string;
name: string;
brand?: string;
barcode?: string;
barcode?: string; // EAN-13 / UPC-A, digits only
category: ProductCategory;
servingSize: number;
servingUnit: ServingUnit;
nutrition: NutritionInfo;
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;
isPublic: boolean; // Visible to all households (for shared catalog)
source: ProductSource; // 'manual' | 'barcode_lookup' | 'llm' | 'import'
source: ProductSource;
createdBy: string; // userId
createdAt: Date;
updatedAt: Date;
deletedAt?: Date; // soft delete
}
export interface NutritionInfo {
@ -81,14 +88,14 @@ export enum ProductCategory {
export enum ServingUnit {
GRAMS = 'g',
MILLILITERS = 'ml',
OUNCES = 'oz',
CUPS = 'cup',
TABLESPOONS = 'tbsp',
TEASPOONS = 'tsp',
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',
@ -104,11 +111,13 @@ export enum ProductSource {
{ name: 'text', brand: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household
{ 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
@ -126,15 +135,22 @@ export enum ProductSource {
| 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
?q=chicken # Full-text search (name, brand, tags)
&category=meat # Filter by category
&tags=organic,fresh # Filter by tags (AND)
&cursor=abc123 # Cursor-based pagination
&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
&sort=name|-updatedAt # Sort field, prefix - for desc; default -updatedAt
```
### Response Shape
@ -157,30 +173,40 @@ interface PaginatedResponse<T> {
### 5.1 — Shared Types & Validation
- Add all types above to `packages/shared/src/types/product.ts`
- Add enums to `packages/shared/src/enums/`
- Create Zod schemas:
- `CreateProductSchema` — validates create payload
- `UpdateProductSchema` — partial, validates update payload
- `ProductQuerySchema` — validates query params
- 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`
- `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)` — supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- `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)`
- `bulkCreate(items[])`
- `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
- If not found, query Open Food Facts API (`https://world.openfoodfacts.org/api/v2/product/{barcode}`)
- Map OFF response to `Product` shape
- Cache results in local DB with `source: 'barcode_lookup'`
- 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
@ -214,38 +240,46 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
### 5.6 — Import Endpoint
- `POST /products/import` accepts multipart CSV or JSON file
- Validate each row against `CreateProductSchema`
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
- CSV column mapping: `name, brand, barcode, category, servingSize, servingUnit, calories, protein, carbs, fat, ...`
- `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:
- Search bar with debounced full-text search
- `/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)
- Each product card shows: name, brand, category icon, calories/serving
- 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
- Form fields for all product properties; `servingUnit` select limited to `g | ml | piece | slice`
- Nutrition input section with per-serving values
- Barcode field with "Lookup" button
- 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 and error display
- Import dialog: file upload with preview, row count, and error display
---
## Acceptance Criteria
- [ ] Can create, read, update, delete products via API
- [ ] Full-text search returns relevant results
- [ ] Barcode lookup fetches from Open Food Facts when not in local DB
- [ ] Bulk import processes a CSV with 100+ products
- [ ] 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 "not available" with NoOp provider
- [ ] 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)
---

View file

@ -50,13 +50,21 @@ export interface Recipe {
export interface RecipeIngredient {
productId: string; // Reference to Product
productName: string; // Denormalized for display
quantity: number;
unit: ServingUnit;
quantity: number; // stored in metric (g | ml) or as a discrete count
unit: RecipeUnit; // metric/discrete only after normalization
originalQuantity?: number; // optional, preserved from import (e.g. 1)
originalUnit?: ImperialUnit | RecipeUnit; // optional, preserved from import (e.g. 'cup')
preparation?: string; // e.g., 'diced', 'minced', 'melted'
isOptional: boolean;
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
}
// Storage units for recipe ingredients — same set as `ServingUnit` in Phase 5.
export type RecipeUnit = 'g' | 'ml' | 'piece' | 'slice';
// Accepted at input/import only; converted to `RecipeUnit` before persistence.
export type ImperialUnit = 'oz' | 'lb' | 'cup' | 'tbsp' | 'tsp' | 'fl_oz';
export interface RecipeStep {
order: number;
instruction: string;
@ -143,7 +151,7 @@ class NutritionCalculatorService {
/**
* For each ingredient:
* 1. Lookup the product by productId
* 2. Convert ingredient quantity/unit to product's servingUnit
* 2. Convert ingredient quantity/unit to product's servingUnit (already metric)
* 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving
* 4. Sum across all ingredients → totalNutrition
* 5. Divide by servings → perServingNutrition
@ -160,17 +168,26 @@ class NutritionCalculatorService {
}
```
- Unit conversion helper: handle common conversions (g ↔ oz, ml ↔ cups, etc.)
- Not all conversions are possible (density-dependent) — log warning, use best approximation
- This is explicitly **informative, not clinical-grade accurate**
- Because products are stored in metric (`g | ml | piece | slice`), the calculator only needs to bridge metric ↔ metric and discrete ↔ metric (via `Product.servingSize`).
- Imperial input handling lives in `UnitConversionService` (see 6.2a) and runs **before** the calculator at create/update/import time.
### 6.2a — Unit Conversion Service
- `UnitConversionService.toMetric(quantity, unit, product)` returns `{ quantity, unit: RecipeUnit }`.
- Mass conversions (exact): `oz → g` (× 28.3495), `lb → g` (× 453.592).
- Volume conversions (exact, US customary): `tsp → ml` (× 4.92892), `tbsp → ml` (× 14.7868), `fl_oz → ml` (× 29.5735), `cup → ml` (× 236.588).
- Mass ↔ volume conversions require `product.densityGPerMl`. If absent, the service returns an error tagged `MISSING_DENSITY` and the route returns 422 with the offending ingredient so the user can either supply a density on the product or restate the quantity in the product's native unit.
- Discrete units (`piece`, `slice`) cannot be converted from imperial — reject at validation.
- This service is **input-side only**: ingredients persisted on a recipe are always already in `RecipeUnit`.
### 6.3 — Recipe CRUD with Auto-Calculation
- On `POST /recipes` and `PATCH /recipes/:id`:
1. Validate ingredients exist in product library
2. Call `NutritionCalculatorService.calculateRecipeNutrition()`
3. Call `NutritionCalculatorService.generateWarnings()`
4. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
1. Validate ingredients exist in product library (including soft-deleted)
2. Run each ingredient through `UnitConversionService.toMetric()` so persisted `unit` is always `RecipeUnit`; preserve the user's original input as `originalQuantity`/`originalUnit` for display
3. Call `NutritionCalculatorService.calculateRecipeNutrition()`
4. Call `NutritionCalculatorService.generateWarnings()`
5. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
- On product nutrition update (Phase 5 edit), trigger background recalculation:
- Find all recipes where `ingredients[].productId == updatedProductId`
- Recalculate each recipe's nutrition
@ -186,9 +203,9 @@ class NutritionCalculatorService {
- `POST /recipes/import-text`:
- Accepts `{ text: string }` (pasted recipe)
- Calls `ILlmProvider.parseRecipe(text)`
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }`
- Service attempts to match ingredient names to existing products (fuzzy match by name)
- Returns structured recipe for user review — unmatched ingredients flagged for manual product creation
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }` where `unit` may be imperial
- Service runs each ingredient through `UnitConversionService.toMetric()` and matches names to existing products (fuzzy match by name)
- Returns the structured recipe for user review — unmatched ingredients and `MISSING_DENSITY` failures are flagged for manual resolution; nothing is persisted yet
- `POST /recipes/import-url`:
- Calls `ILlmProvider.parseRecipeFromUrl(url)`
- Same flow as text import