Estructura API
La API del ERP es un servidor Express + TypeScript (ESM) organizado por
módulos de dominio: ~106 carpetas en web/api/src/modules/, cada una
con sus *.routes.ts y *.service.ts. El montaje de todo (middleware +
~250 app.use) vive en src/app.ts (createApp()).
Estructura de directorios
web/api/src/
├── app.ts # createApp(): middleware + montaje de rutas (~1100 líneas)
├── index.ts # Punto de entrada (listen)
├── config/ # env.ts (variables tipadas), configuración
├── auth/ # jwt.ts — verificación local + JWKS de zentto-auth
├── db/
│ ├── query.ts # callSp, callSpOut, query, helpers PII
│ ├── pg-pool-manager.ts # Pools por tenant (dbName → Pool)
│ ├── tenant-resolver.ts # CompanyId → BD (cache 5 min)
│ └── provision-tenant-db.ts# Provisioning de BDs dedicadas
├── middleware/ # 15 middlewares (ver página Middleware)
├── modules/ # ~106 módulos de dominio
│ ├── inventario/
│ │ ├── inventario.routes.ts
│ │ ├── inventario.service.ts
│ │ └── types.ts
│ ├── ventas/ compras/ contabilidad/ pos/ crm/ nomina/ ...
│ ├── backoffice/ # Panel interno (multi-tenant)
│ └── _shared/ # Clientes HTTP compartidos (zentto-auth, etc.)
├── jobs/ # Jobs periódicos (retry de webhooks, scheduler contable...)
├── webhooks/ # Receptores raw (Paddle, GitHub)
├── lib/ # openapi-doc.ts, utilidades
└── utils/ # api-error.ts, helpers Superficies y prefijos
/v1/*— prefijo canónico (~87 prefijos de módulo:/v1/inventario,/v1/ventas,/v1/crm,/v1/nomina,/v1/backoffice, …). Protegido en bloque porrequireJwt./api/v1/*— alias espejo que re-monta los mismos routers con la misma cadena de middleware (compatibilidad con clientes que llegan por el proxy del frontend).- Rutas públicas (sin JWT):
/health,/store,/shipping,/api/landing,/api/tenant-public,/public/*(aprobaciones, tracking CRM),/v1/registro,/v1/catalog,/v1/status,/v1/license,/v1/billing/webhook, webhooks raw. - Backoffice:
/v1/backoffice/*con su propia autenticación (master key + TOTP — ver Backoffice).
Patrón de módulo
routes.ts — endpoints con validación Zod
// web/api/src/modules/inventario/inventario.routes.ts
import { Router } from 'express';
import { z } from 'zod';
import * as service from './inventario.service.js';
import { ApiError } from '../../utils/api-error.js';
const router = Router();
// El JWT ya fue validado por requireJwt a nivel de /v1 — no se repite aquí.
const listQuery = z.object({
search: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
});
router.get('/productos', async (req, res, next) => {
try {
const q = listQuery.parse(req.query);
const companyId = req.companyId!; // resuelto por el middleware de auth/tenant
res.json(await service.listProducts(companyId, q.search ?? null, q.page));
} catch (err) {
next(err); // SIEMPRE delegar al global error handler
}
});
router.post('/productos', async (req, res, next) => {
try {
const body = productInput.parse(req.body);
const result = await service.createProduct(req.companyId!, body);
if (!result.ok) throw new ApiError(400, 'invalid_product', result.message);
res.status(201).json(result);
} catch (err) {
next(err);
}
});
export default router; Manejo de errores (regla ALERT-3)
Nunca res.status(500).json({ error: String(err) }) — expone stack
trace. El patrón canónico es catch (err) { next(err); } o lanzar
new ApiError(status, code, mensaje) (utils/api-error.ts). El handler
global (middleware/error-handler.ts) reconoce ApiError, errores de parseo JSON y
Zod, y jamás expone stack en producción. Las routes viejas con String(err) se
migran en cada PR que las toque.
service.ts — lógica de negocio
// web/api/src/modules/inventario/inventario.service.ts
import { callSp } from '../../db/query.js';
export async function listProducts(companyId: number, search: string | null, page: number) {
const rows = await callSp('usp_master_product_list', {
CompanyId: companyId, Search: search, PageSize: 20, PageNumber: page,
});
return { items: rows, total: rows[0]?.TotalCount ?? 0 };
}
El servicio no maneja la conexión: callSp() resuelve el pool del tenant activo
(AsyncLocalStorage del request) y fija el company_id para RLS. Ver
Helpers de BD.
Registro de rutas en app.ts
// web/api/src/app.ts (esquema real, resumido)
app.use(helmet({ ... }));
app.use(cors({ whitelist + *.zentto.net, credentials: true }));
app.use('/api/webhooks', rawWebhooks); // ANTES de express.json (firma raw)
app.use(express.json({ limit: '2mb' }));
app.use(observabilityMiddleware); // @zentto/obs
app.use(csrfOrigin); // valida Origin en mutaciones
app.use(subdomainTenantMiddleware); // tenant por subdominio
// públicos: /health, /store, /shipping, /v1/registro, ...
app.use('/v1', requireJwt); // auth en bloque
app.use('/v1', autoRbac); // RBAC por módulo+método
app.use('/v1', normalizeRequestDateTimesToUtc);
app.use('/v1', localizeResponseDateTimes);
app.use('/v1', auditTrailMiddleware);
app.use('/v1/inventario', inventarioRoutes); // ~90 routers de negocio
app.use('/v1/ventas', ventasRoutes);
// ... + alias espejo /api/v1/*
await loadAddons(app); // addons dinámicos
app.use(globalErrorHandler); // SIEMPRE al final Orden importa
Un router montado antes de app.use('/v1', requireJwt) queda
sin autenticación. Todo router nuevo de negocio se monta después del bloque
de middleware de /v1, salvo que sea deliberadamente público (y entonces se lista
junto a los públicos, con revisión de seguridad).
Patrones de request/response
Lista paginada
{ "items": [ { "ProductId": 1, "Name": "Widget A", ... } ], "total": 142 } Escritura
{ "ok": true, "id": 143, "message": "Producto creado" } Error (formato del global handler)
{ "error": "invalid_product", "message": "SKU ya existe" } // 4xx con código estable
{ "error": "internal_error" } // 500 — sin detalles internos Shape mismatch = grid vacío
Si un grid sale vacío con la API respondiendo 200, casi siempre el frontend espera otra forma
(items vs data, total vs TotalCount).
El contrato OpenAPI es la referencia; el CI audita la cobertura
(Contrato API).
Contrato OpenAPI
Todo endpoint se documenta en web/contracts/openapi.yaml (~880 paths) antes de
implementarse en frontend. El CI corre npm run audit:openapi (gate bloqueante) y
los SP Contract Tests contra un PostgreSQL de servicio. Detalle en
Contrato API.