# 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(...))`.

**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/<version> (+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<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`

**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.
