Añadir un módulo a la API
El patrón de cuatro archivos (routes / handlers / service / schemas) para un nuevo dominio en apps/api.
Cada dominio de la API (apps/api/src/modules/<dominio>/) sigue la misma estructura
de cuatro archivos. El ejemplo de referencia más limpio es apps/api/src/modules/cash/.
Los cuatro archivos
| Archivo | Responsabilidad |
|---|---|
routes.ts | Registra las rutas en Fastify y aplica los preHandler (RBAC). No tiene lógica. |
handlers.ts | Recibe la request, valida el body con Zod (schema.parse(...)), lee req.tenant/req.user, delega al service y formatea la respuesta. |
service.ts | Lógica de negocio y queries con Drizzle. Siempre filtra por tenantId. No conoce req/reply. |
schemas.ts | Esquemas Zod 4 de entrada + los tipos derivados con z.infer. |
Nota: Los imports entre archivos del módulo usan extensión
.js(ESM compilado):import * as cashService from './service.js'. No es un error tipográfico.
Convenciones clave
- Multi-tenant: el plugin
tenant-scopeinyectareq.tenant. Toda query del service recibereq.tenant.idcomo primer argumento y filtra por él. El JWT también exponereq.user.userId,req.user.branchIdyreq.user.role. - Zod 4: usa
z.record(z.string(), z.unknown()), noz.record(z.unknown()). Coacciona números de formularios conz.coerce.number(). - Errores de validación: lanzar
schema.parse()es suficiente — elerror-handlerglobal capturaZodErrory devuelve losissuescon la ruta del campo.
Pasos
1. Crea la carpeta y los cuatro archivos
mkdir -p apps/api/src/modules/widgets
cd apps/api/src/modules/widgets
touch routes.ts handlers.ts service.ts schemas.ts2. schemas.ts — validación de entrada
import { z } from 'zod';
export const createWidgetSchema = z.object({
name: z.string().min(1, 'Nombre requerido').max(255),
color: z.string().max(50).optional(),
});
export type CreateWidgetInput = z.infer<typeof createWidgetSchema>;3. service.ts — lógica de negocio (siempre con tenantId)
import { db } from '../../db/index.js';
import { widgets } from '../../db/schema/widgets.js';
import { eq, and, desc } from 'drizzle-orm';
import type { CreateWidgetInput } from './schemas.js';
export async function listWidgets(tenantId: string) {
return db
.select()
.from(widgets)
.where(eq(widgets.tenantId, tenantId))
.orderBy(desc(widgets.createdAt));
}
export async function getWidgetById(tenantId: string, id: string) {
const [row] = await db
.select()
.from(widgets)
.where(and(eq(widgets.tenantId, tenantId), eq(widgets.id, id)))
.limit(1);
return row ?? null;
}
export async function createWidget(tenantId: string, input: CreateWidgetInput) {
const [row] = await db
.insert(widgets)
.values({ ...input, tenantId })
.returning();
return row;
}4. handlers.ts — request → service → response
import type { FastifyRequest, FastifyReply } from 'fastify';
import * as widgetService from './service.js';
import { createWidgetSchema } from './schemas.js';
export async function listWidgetsHandler(req: FastifyRequest, reply: FastifyReply) {
return reply.send(await widgetService.listWidgets(req.tenant.id));
}
export async function getWidgetHandler(req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
const widget = await widgetService.getWidgetById(req.tenant.id, id);
if (!widget) return reply.status(404).send({ error: 'Widget not found' });
return reply.send(widget);
}
export async function createWidgetHandler(req: FastifyRequest, reply: FastifyReply) {
const body = createWidgetSchema.parse(req.body);
const widget = await widgetService.createWidget(req.tenant.id, body);
return reply.status(201).send(widget);
}5. routes.ts — registro y protección RBAC
import type { FastifyInstance } from 'fastify';
import {
listWidgetsHandler,
getWidgetHandler,
createWidgetHandler,
} from './handlers.js';
import { requireModule } from '../../plugins/rbac.js';
export async function widgetRoutes(app: FastifyInstance) {
app.get('/api/widgets', { preHandler: requireModule('widgets') }, listWidgetsHandler);
app.get('/api/widgets/:id', { preHandler: requireModule('widgets') }, getWidgetHandler);
app.post('/api/widgets', { preHandler: requireModule('widgets') }, createWidgetHandler);
}6. Registra el módulo en apps/api/src/app.ts
Añade el import junto a los demás módulos (arriba del archivo) y el app.register(...)
en el bloque de rutas:
// imports
import { widgetRoutes } from './modules/widgets/routes.js';
// ...dentro de la función que registra rutas, junto a los demás:
await app.register(widgetRoutes);Proteger rutas con requireModule(...)
requireModule(module) (en apps/api/src/plugins/rbac.ts) es un preHandler que:
- deja pasar siempre al rol
admin; - para cualquier otro rol, resuelve sus permisos (built-in o rol personalizado desde la
tabla
roles, con caché de 60 s) y responde 403 si el permiso del módulo no está.
El string que le pasas ('widgets') es el nombre del módulo de permiso. Para que sea
asignable a roles personalizados debe existir en SYSTEM_MODULES (ver
apps/api/src/plugins/rbac-config.ts).
app.post('/api/widgets', { preHandler: requireModule('widgets') }, createWidgetHandler);Rutas públicas (sin token)
Por defecto todas las rutas requieren JWT. Si tu módulo necesita una ruta pública
(p. ej. un endpoint llamado desde la landing o desde un kiosko), añade su prefijo a la
lista AUTH_PUBLIC en apps/api/src/plugins/auth.ts, o agrega una regla a
isAuthPublic(...) para casos por método/patrón:
// apps/api/src/plugins/auth.ts
const AUTH_PUBLIC = [
'/api/auth/login',
'/api/portal/',
// ...
'/api/widgets/public/', // ← tu prefijo público
];Nota: Una ruta en
AUTH_PUBLICno tienereq.tenantnireq.user. Tu service tendrá que resolver el tenant por otra vía (slug, subdominio, etc.), igual que hace el móduloportal.
Excepciones al patrón
No todos los módulos son exactamente cuatro archivos:
instruments/añade un subdirectorioparsers/(astm.ts,csv.ts,hl7.ts,xml.ts) para los formatos de los equipos de laboratorio, autenticados por el plugininstrument-auth.appointments/añadereminder-service.tsyappointment-notifications.ts.
Mantén routes / handlers / service / schemas como base y agrega archivos extra solo
cuando haya una responsabilidad clara que separar.
Checklist
- Carpeta
apps/api/src/modules/<dominio>/con los 4 archivos. schemas.tscon Zod +z.infer.service.tsfiltrando portenantId.handlers.tsque valida con.parse()y delega.routes.tsconrequireModule(...)en cada ruta protegida.import+app.register(...)enapp.ts.- (Opcional) prefijo en
AUTH_PUBLICsi hay rutas públicas.
Siguiente: Añadir una tabla al schema · Escribir una server action.