# Architect — F-010 Variants, SKU/EAN and product rich data done -> work/artifacts/F-010/architect.md ## Deliverables - Extend `src/modules/catalog/` with product variants and rich product data. - PostgreSQL migration for `catalog_product_variants` and `catalog_product_rich_data`. - Unit and integration tests for duplicate SKU/EAN, nutrition provenance persistence, and manual nutrition protection. ## Key decisions 1. **Catalog owns variants and rich data**: variants are part of the product aggregate in this slice. Do not create a new module yet; inventory/pricing later reference variant IDs through catalog public contracts. 2. **Variants are product-scoped sellable identities**: `catalog_product_variants` has `product_id`, `sku`, `ean`, `attributes jsonb`, timestamps. `sku` and `ean` are globally unique when present. `ean` can be nullable if a product lacks barcode, but duplicate non-null EAN must conflict. 3. **Rich data is product-level in v1**: ingredients, allergens, nutrition, organic/ecological certification live in `catalog_product_rich_data` keyed by `product_id`. Variant-specific rich data can come later if needed. 4. **Provenance is explicit and stored with nutrition**: allowed nutrition sources are `manual`, `manufacturer`, `openfoodfacts`. Store `nutrition_source` plus `nutrition jsonb` together. Every nutrition payload must have a source. 5. **Trusted internal data wins**: when current `nutrition_source = 'manual'`, updates from `manufacturer` or `openfoodfacts` must not overwrite `nutrition` or `nutrition_source`. Return the existing row unchanged for those fields. Manual can overwrite any source; manufacturer/openfoodfacts can overwrite each other unless current source is manual. 6. **No OpenFoodFacts sync job**: this slice exposes an explicit update use case/API that can receive external-source payloads, but no scheduled import. 7. **No images, no prices, no stock**: those remain out of scope. Do not add sellable pricing/inventory semantics. 8. **No new dependencies**: use existing Zod/pg/Vitest. ## Suggested API contract - `POST /products/:id/variants` → admin-only create variant; duplicate SKU/EAN returns `409 PRODUCT_VARIANT_CODE_EXISTS`. - `PATCH /products/:id/variants/:variantId` → admin-only update variant attributes/SKU/EAN. - `GET /productos/:slug` should include ordered `variants` and rich data summary if available. - `PATCH /products/:id/rich-data` → admin-only upsert rich data. Payload includes optional `ingredients`, `allergens`, certification fields, and `nutrition` + required `nutritionSource` when nutrition is present. ## Domain model additions - `ProductVariant`: `id`, `productId`, `sku`, `ean`, `attributes`, `createdAt`, `updatedAt`. - `NewProductVariant`: `sku`, optional nullable `ean`, optional `attributes` record. - `ProductRichData`: `productId`, `ingredients`, `allergens`, `nutrition`, `nutritionSource`, `isOrganic`, `organicCertification`, `createdAt`, `updatedAt`. - `NutritionSource = 'manual' | 'manufacturer' | 'openfoodfacts'`. ## Error mapping - Duplicate SKU or EAN → `409 PRODUCT_VARIANT_CODE_EXISTS`. - Missing product/variant → `404 NOT_FOUND`. - Nutrition payload without source → `400 VALIDATION_ERROR`. ## Test plan - Unit: manual nutrition source blocks external overwrite and preserves existing manual nutrition. - Unit: external source can update when current source is not manual. - Integration/API: duplicate SKU returns 409. - Integration/API: duplicate EAN returns 409. - Integration/API: nutrition rich data stores payload and provenance. - Integration/API: external update after manual source leaves manual nutrition unchanged. ## Security posture - All mutations are admin-only via injected shared auth and `requireRole`. - Public product reads expose only catalog data intended for storefront display. - Variant attributes and nutrition JSON are validated as bounded records/objects; no arbitrary huge payloads. - SQL remains parameterized; dynamic update columns must be whitelisted. ## Risks - JSONB payloads can become a dumping ground. Keep fields explicit and bounded at API validation. - Manual-source protection must happen in application/use-case logic, not only in route code, so future import jobs cannot bypass it.