56 lines
3.6 KiB
Markdown
56 lines
3.6 KiB
Markdown
# 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.
|