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