# 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_=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/`, 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/`, 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=` 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=` and by category with `GET /products/search?categorySlug=`. - 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/`, 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/`; category pages at `/categoria/`; brand pages at `/marca/`; 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 _ 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.