# Architect — F-009 Brands module

done -> work/artifacts/F-009/architect.md

## Deliverables
- `src/modules/brands/` with domain, application, infrastructure and api layers.
- PostgreSQL migration for `brands_brands` and catalog product brand assignment.
- Catalog search updated so products are filterable by brand.
- Unit and integration tests covering duplicate brand slug, public brand slug URL, and product listing filter by brand.

## Key decisions
1. **Brands is its own module**: brands owns `brands_brands`. This keeps brand SEO pages independent from catalog product internals and matches the backlog wording allowing a dedicated brands module.
2. **Catalog owns the assignment field**: add nullable `brand_id` to `catalog_products` because product-brand assignment is product-owned. It references `brands_brands(id)` for integrity. Catalog may validate/query brand IDs/slugs at repository boundary, but must not import `modules/brands/*` internals.
3. **Slug as public brand identity**: brands exposes `GET /marca/:slug`; internal UUID may exist but public URL must be `/marca/<slug>`.
4. **Brand SEO metadata is first-class**: `brands_brands` stores `seo_title` and `seo_description` alongside `name` and `slug`.
5. **Product filtering by brand**: extend catalog public search with `brandSlug` query parameter. Search remains active-only. Repository joins/filters via `brands_brands.slug` using parameterized SQL.
6. **Admin-only mutations**: brand create/update routes require injected shared auth + `requireRole('admin')`.
7. **No new dependencies**: existing Fastify/Zod/pg/Vitest stack is enough.

## Suggested API contract
- `GET /marca/:slug` → public brand by slug; response includes `url: /marca/<slug>`.
- `POST /brands` → admin-only create brand; duplicate slug returns `409 BRAND_SLUG_EXISTS`.
- `PATCH /brands/:id` → admin-only update brand metadata/slug.
- `GET /products/search?brandSlug=<slug>` → public active product listing filtered by brand.
- `POST /products` / `PATCH /products/:id` accept optional nullable `brandId`.

## Domain model
- `Brand`: `id`, `name`, `slug`, `seoTitle`, `seoDescription`, `createdAt`, `updatedAt`.
- `NewBrand`: `name`, `slug`, optional SEO metadata.
- `BrandPatch`: optional editable fields.
- Extend catalog `Product`: nullable `brandId`.
- Extend catalog `NewProduct`/`ProductPatch`: optional nullable `brandId`.
- Extend `ProductSearch`: optional `brandSlug`.

## Error mapping
- Duplicate brand slug → `409 BRAND_SLUG_EXISTS`.
- Unknown brand assignment on product create/update → `422 PRODUCT_BRAND_NOT_FOUND`.
- Missing brand/product → `404 NOT_FOUND`.

## Test plan
- Unit: brand duplicate error mapping at repository/use-case boundary where practical.
- Unit: product search passes brand filter and remains active-only.
- Integration/API: duplicate brand slug returns HTTP 409.
- Integration/API: `GET /marca/<slug>` returns brand response with slug URL.
- Integration/API: product search filtered by `brandSlug` includes active matching products and excludes other brands.

## Security posture
- Public brand reads are unauthenticated and expose only public SEO metadata.
- Brand mutations and product brand assignment mutations are admin-only.
- SQL must remain parameterized; dynamic update columns must be whitelisted.
- Do not trust client-supplied brand names/slugs for product listing: filter server-side by stored brand relation.

## Risks
- This feature crosses brands and catalog. Keep coupling at DB IDs/slugs in catalog infrastructure; TypeScript imports from catalog to brands internals remain forbidden.
- `/marca/:slug` is a data endpoint in this backend slice, not a storefront-rendered page; storefront page remains out of scope.
