MeshiTrack/docs/phases/phase-5-product-library.md
2026-05-14 14:47:23 +09:00

14 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

  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

// 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/: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

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}$/
    • UpdateProductSchemaCreateProductSchema.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_namename; 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:
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.