ixiclinicDocs
DesarrolladoresGuías

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

ArchivoResponsabilidad
routes.tsRegistra las rutas en Fastify y aplica los preHandler (RBAC). No tiene lógica.
handlers.tsRecibe la request, valida el body con Zod (schema.parse(...)), lee req.tenant/req.user, delega al service y formatea la respuesta.
service.tsLógica de negocio y queries con Drizzle. Siempre filtra por tenantId. No conoce req/reply.
schemas.tsEsquemas 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-scope inyecta req.tenant. Toda query del service recibe req.tenant.id como primer argumento y filtra por él. El JWT también expone req.user.userId, req.user.branchId y req.user.role.
  • Zod 4: usa z.record(z.string(), z.unknown()), no z.record(z.unknown()). Coacciona números de formularios con z.coerce.number().
  • Errores de validación: lanzar schema.parse() es suficiente — el error-handler global captura ZodError y devuelve los issues con 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.ts

2. 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_PUBLIC no tiene req.tenant ni req.user. Tu service tendrá que resolver el tenant por otra vía (slug, subdominio, etc.), igual que hace el módulo portal.

Excepciones al patrón

No todos los módulos son exactamente cuatro archivos:

  • instruments/ añade un subdirectorio parsers/ (astm.ts, csv.ts, hl7.ts, xml.ts) para los formatos de los equipos de laboratorio, autenticados por el plugin instrument-auth.
  • appointments/ añade reminder-service.ts y appointment-notifications.ts.

Mantén routes / handlers / service / schemas como base y agrega archivos extra solo cuando haya una responsabilidad clara que separar.

Checklist

  1. Carpeta apps/api/src/modules/<dominio>/ con los 4 archivos.
  2. schemas.ts con Zod + z.infer.
  3. service.ts filtrando por tenantId.
  4. handlers.ts que valida con .parse() y delega.
  5. routes.ts con requireModule(...) en cada ruta protegida.
  6. import + app.register(...) en app.ts.
  7. (Opcional) prefijo en AUTH_PUBLIC si hay rutas públicas.

Siguiente: Añadir una tabla al schema · Escribir una server action.

On this page