# MercadoDeVida backend — modular monolith skeleton

TypeScript + Fastify modular monolith. Simple code, clear modules, small changes, no magic.

## Requirements

- Node.js >= 22
- npm

## Commands

```bash
npm install           # install dependencies
npm run build         # compile to dist/
npm start             # run compiled server (PORT, HOST env vars)
npm test              # vitest unit tests (no database needed)
npm run typecheck     # tsc --noEmit
npm run lint          # eslint + prettier check
npm run lint:boundaries  # module boundary check
```

Frontend shell commands live in `storefront/`:

```bash
cd storefront
npm install
npm run dev        # Next.js development server
npm run lint       # storefront formatting check
npm run typecheck  # frontend TypeScript check
npm run build      # production Next.js build
```

## Configuration

Startup is fail-fast: `src/infrastructure/config` parses env once and refuses to boot
on missing/invalid required vars. `DATABASE_URL` is required; `PORT`, `HOST`,
`LOG_LEVEL`, `NODE_ENV`, `REDIS_URL` are optional with defaults. `COOKIE_SECURE`
defaults to `true` (set `false` only for local http dev). Errors name variable
NAMES only, never values.

Feature flags: `FLAG_<NAME>=true|false` env vars seed the flag store at boot. Unknown
flags default to OFF (fail-safe). Flags flip at runtime through the store — activation
is separate from deployment (no redeploy). Copy `.env.example` to `.env` to start.

## HTTP contract

- Every response carries an `x-request-id` header (propagated from a safe incoming
  `x-request-id`, or a fresh UUID). Every JSON log line for a request carries the same id.
- Errors always use one envelope:
  `{ "error": { "statusCode", "code", "message", "details?" }, "requestId" }`
  Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
  Category-specific codes: `CATEGORY_SLUG_EXISTS` (409), `CATEGORY_PARENT_NOT_FOUND` (422),
  `CATEGORY_TREE_CYCLE` (422), `CATEGORY_HAS_CHILDREN` (409). Product-specific codes:
  `PRODUCT_SLUG_EXISTS` (409), `PRODUCT_CATEGORY_NOT_FOUND` (422),
  `PRODUCT_BRAND_NOT_FOUND` (422), `PRODUCT_VARIANT_CODE_EXISTS` (409).
  Brand-specific codes: `BRAND_SLUG_EXISTS` (409).
  5xx messages are always generic; stack traces stay in server logs only.
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
- Auth codes: `UNAUTHORIZED` (401, missing/invalid/revoked session),
  `FORBIDDEN` (403, role or ownership check failed), `INVALID_CREDENTIALS` (401),
  `EMAIL_ALREADY_REGISTERED` (409), `TOO_MANY_ATTEMPTS` (429, with `Retry-After` header).
- Log level via `LOG_LEVEL` env var (default `info`); logs are JSON only.

## Authentication (identity module)

The server is the only authority for identity; the frontend is never trusted with
session or credential state.

| Route               | Result                                                    |
| ------------------- | --------------------------------------------------------- |
| POST /auth/register | `201` + `{ id, email, role, createdAt }`                  |
| POST /auth/login    | `200` + `{ id, email, role }` + `Set-Cookie: mdv_session` |
| POST /auth/logout   | `204`, cookie cleared, session revoked (idempotent)       |

- Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never
  plaintext or anything reversible.
- Sessions: opaque 512-bit token in the cookie; the DB stores only its SHA-256
  hash (`identity_sessions.token_hash`). TTL 7 days; logout revokes server-side.
- Cookie: `HttpOnly`, `Secure` (`COOKIE_SECURE`, default true), `SameSite=Lax`,
  `Path=/`, `Max-Age=604800`.
- Login failures: identical generic 401 for unknown email and wrong password (no
  enumeration; timing equalized via dummy hash). After 10 consecutive failures per
  email, further attempts get `429` with `Retry-After` for 15 minutes. The limiter
  is in-memory per instance behind a `LoginRateLimiter` interface (Redis-backed
  swap later without touching use cases).
- Identity routes are wired only when the app is built with a DB pool.

## Users and RBAC (users module)

Every route resolves the session cookie against the DB first (expired/revoked
sessions and missing cookies get `401 UNAUTHORIZED`). Roles are `customer`
(default) and `admin`; the role is read from `identity_users` on every request,
so promotions/demotions apply immediately. Role changes are an out-of-band DB
operation in this slice (no admin API yet).

| Route                                  | Access         | Result                             |
| -------------------------------------- | -------------- | ---------------------------------- |
| GET /users                             | admin only     | `200` + `{ items: [profile] }`     |
| GET /users/:id                         | owner or admin | `200` profile, `404` if none yet   |
| PATCH /users/:id                       | owner or admin | `200` upserted profile             |
| GET /users/:id/addresses               | owner or admin | `200` + `{ items: [address] }`     |
| POST /users/:id/addresses              | owner or admin | `201` address                      |
| PATCH /users/:id/addresses/:addressId  | owner or admin | `200` address, `404` if not theirs |
| DELETE /users/:id/addresses/:addressId | owner or admin | `204`, `404` if not theirs         |

- Authorization is checked before existence: a non-owner gets `403 FORBIDDEN`
  regardless of whether the target resource exists (no enumeration).
- Address queries are scoped by `user_id` in SQL, so a valid foreign address id
  is unreachable.
- `GET /users` lists users that have a profile row (users who have patched their
  profile at least once).
- The users module never imports identity: session resolution arrives as an
  injected `Authenticate` function from the composition root.

## Categories (categories module)

Categories provide the public taxonomy for SEO-friendly catalog URLs. The module
owns `categories_categories`; products are not assigned to categories until the
catalog slice.

| Route                  | Access     | Result                                      |
| ---------------------- | ---------- | ------------------------------------------- |
| GET /categories/tree   | public     | `200` + `{ items: [categoryTreeNode] }`     |
| GET /categoria/:slug   | public     | `200` category by slug, `404` if missing    |
| POST /categories       | admin only | `201` category, `409` on duplicate slug     |
| PATCH /categories/:id  | admin only | `200` category, `422` on invalid tree move  |
| DELETE /categories/:id | admin only | `204`, `409` when the category has children |

- Public URLs are slug-based: `/categoria/<slug>`, never internal ids.
- Slugs are globally unique for this slice.
- Category hierarchy uses `parentId`; self-parenting and descendant-as-parent moves
  are rejected with `CATEGORY_TREE_CYCLE`.
- Category rows include `seoTitle` and `seoDescription`.

## Catalog core (catalog module)

Catalog owns product identity, variants, rich product data, product images and public product discovery.
Stock, prices and external sync jobs are intentionally outside this slice.

| Route                                   | Access     | Result                                                  |
| --------------------------------------- | ---------- | ------------------------------------------------------- |
| GET /productos/:slug                    | public     | `200` active product by slug, `404` otherwise           |
| GET /products/search                    | public     | `200` + `{ items: [activeProduct] }` via PostgreSQL FTS |
| POST /products                          | admin only | `201` product, `409` on duplicate slug                  |
| PATCH /products/:id                     | admin only | `200` product, `422` on unknown category                |
| GET /products/:id/variants              | public     | `200` + `{ items: [variant] }`                          |
| POST /products/:id/variants             | admin only | `201` variant, `409` on duplicate SKU/EAN               |
| PATCH /products/:id/variants/:variantId | admin only | `200` variant, `404` if missing                         |
| PATCH /products/:id/rich-data           | admin only | `200` rich data with nutrition provenance               |
| GET /products/:id/images                | public     | `200` + `{ items: [image] }`                            |
| POST /products/:id/images               | admin only | `201` image, `404` on missing product                   |
| DELETE /products/:id/images/:imageId    | admin only | `204`, `404` if missing                                 |
| PATCH /products/:id/images/reorder      | admin only | `200` + reordered `{ items: [image] }`                  |

- Public URLs are slug-based: `/productos/<slug>`, never internal ids.
- Product states are `draft`, `active` and `archived`; public reads/search return
  only `active` products.
- Product rows include `seoTitle` and `seoDescription`.
- Category assignment is stored in `catalog_product_categories` and validates
  category ids against `categories_categories` without importing categories internals.
- Product search is behind a `ProductSearchRepository` port so future engines can replace PostgreSQL
  without changing the HTTP API. `GET /products/search?q=<term>` uses PostgreSQL full-text search over
  product, brand and category text with stable pagination/relevance ordering.
- Product reads can be filtered by brand with `GET /products/search?brandSlug=<slug>` and by category
  with `GET /products/search?categorySlug=<slug>`.
- Product brand assignment is stored as `catalog_products.brand_id` and validates
  brand ids against `brands_brands` without importing brands internals.
- Variants live in `catalog_product_variants`; non-null SKU and optional EAN are globally unique.
- Images live in `catalog_product_images`; product pages expose ordered image metadata with `url`,
  `altText`, `position` and `role` (`main` or `gallery`). Images can be product-level or variant-level.
  Storage is behind a catalog infrastructure adapter; this slice stores URLs only, with no binary upload,
  CDN or processing pipeline.
- Rich data lives in `catalog_product_rich_data`; nutrition payloads require `nutritionSource`
  (`manual`, `manufacturer`, `openfoodfacts`).
- Manual nutrition is trusted: external-source updates cannot overwrite existing manual nutrition.
- Search logs structured `catalog_search` telemetry with duration, result count and sanitized bounded
  query metadata for later popular-search/cache work.

## Brands (brands module)

Brands provide public SEO-friendly brand identity and product filtering support.
The module owns `brands_brands`; product assignment remains catalog-owned.

| Route             | Access     | Result                                |
| ----------------- | ---------- | ------------------------------------- |
| GET /brands       | public     | `200` brand list for sitemap/catalog  |
| GET /marca/:slug  | public     | `200` brand by slug, `404` if missing |
| POST /brands      | admin only | `201` brand, `409` on duplicate slug  |
| PATCH /brands/:id | admin only | `200` brand, `404` if missing         |

- Public URLs are slug-based: `/marca/<slug>`, never internal ids.
- Slugs are globally unique for this slice.
- Brand rows include `seoTitle` and `seoDescription`.

## Storefront shell (Next.js)

The customer-facing shell lives in `storefront/` as a separate frontend package. It uses
Next.js App Router, React, TypeScript and Tailwind CSS.

- Home renders as a server component with global layout, navigation and footer.
- Product detail pages live at `/productos/<slug>`; category pages at `/categoria/<slug>`; brand pages at
  `/marca/<slug>`; search results at `/products/search`.
- Product, category and brand pages use ISR (`revalidate = 300`) and generate metadata with canonical URLs
  and OpenGraph fields. Search results use `revalidate = 120`.
- Product pages embed Product JSON-LD; product/category/brand pages embed BreadcrumbList JSON-LD; the root
  layout embeds Organization JSON-LD.
- `sitemap.xml` and `robots.txt` are generated by Next.js metadata routes. Sitemap includes static public URLs,
  active products, categories and brands, and degrades to static URLs if the API is unavailable.
- Permanent SEO redirects are configured with `REDIRECTS_JSON`, an array of local same-origin
  `{ "from": "/old", "to": "/new" }` path entries handled by `src/proxy.ts` with HTTP 301.
- On-demand catalog revalidation is available at `POST /api/revalidate` with `REVALIDATE_SECRET` and
  `x-revalidate-secret`; accepted paths are catalog public paths only.
- The frontend consumes backend public endpoints only through `src/lib/api.ts` typed DTOs.
- `src/lib/api.ts` imports `server-only`, so API calls are not accidentally bundled into client components.
- Backend internals under `src/` are never imported by the storefront.
- Configure API origin with `API_BASE_URL` for server-side rendering or `NEXT_PUBLIC_API_BASE_URL` when needed;
  the local default is `http://localhost:3000`.
- Cart and checkout UI are intentionally outside this slice.

## Database (local dev)

```bash
cp .env.example .env  # once
npm run docker:up     # start PostgreSQL 16 + Redis 7
npm run db:up         # apply migrations
npm run db:status     # list applied migrations
npm run db:down       # revert last migration
npm run test:integration  # integration tests (need TEST_DATABASE_URL from .env)
npm run docker:down   # stop services (add -v to wipe volumes)
```

### Table naming convention

```text
<module>_<table>          e.g. catalog_products, inventory_stock, orders_orders
```

- Every table is prefixed with its owning module.
- A module never queries tables without its own prefix; data flows through module interfaces.
- Migrations are immutable once merged: fixes ship as new migrations.
- No schema change without migration.

## Layout

```text
src/
├── app/             # composition root (only place that wires modules)
├── infrastructure/  # http server, db pool, config, logging
├── modules/         # business modules, one folder each
│   ├── health/      # exemplar module: public API only via index.ts
│   ├── flags/       # feature flags (unknown default OFF, runtime flip)
│   ├── identity/    # register/login/logout, argon2, sessions, rate limit
│   ├── users/       # profile + address CRUD, owner-or-admin RBAC
│   ├── categories/  # category tree, slugs, SEO metadata
│   ├── catalog/     # products, states, slugs, category/brand assignment
│   └── brands/      # brands, slugs, SEO metadata
└── shared/          # cross-cutting helpers (error envelope, input parsing)
```

## Module rules

- A module exposes its public API only through its `index.ts`.
- Files inside a module may import: own subtree, `src/shared`, Node builtins, npm packages.
- Code outside modules (app/infrastructure) may import a module only via its `index.ts`.
- `npm run lint:boundaries` enforces these rules.
