Files
mercadodevida/project/README.md
2026-08-17 22:23:10 +02:00

15 KiB

MercadoDeVida backend — modular monolith skeleton

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

Requirements

  • Node.js >= 22
  • npm

Commands

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/:

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)

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

<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

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.