Skip to main content

Arquitectura del backend

proteus-backend (paquete oxitesa): Node 20 + TypeScript (CommonJS), Express vía routing-controllers, TypeORM sobre PostgreSQL, Redis y RabbitMQ.

El idioma del código, los comentarios y los logs es español.

Un entry point, muchos roles

No hay un proyecto de workers aparte

src/index.ts es la API y es cada worker. El rol lo elige la variable de entorno EXECUTION_MODE.

EXECUTION_MODERol
vacío o normalAPI HTTP, clusterizada por cores
sii-worker*new SIIWorkerManager().start() — el scraping del SII
cup-worker*runWorkFromCupV2() — el circuito de OMEs
cualquier otromain() en WorkerConfigV2.ts (un if/else sobre el string exacto)

En modo API hay clusterización (cluster.fork(), con tope APP_CORE_LIMIT), un watchdog de memoria (el proceso sale si el RSS supera MEMORY_LIMIT_MB, default 600) y un guard de crash-budget (el master sale si los workers mueren más de 5 veces en 60 s, para que Docker reinicie). Solo el container con LEADER=true flushea Redis al bootear.

En modo worker no clusteriza: un solo proceso. Ver Workers y colas.

Los scripts npm run dev-* son solo cross-env EXECUTION_MODE=<x> ts-node src/index.ts.

Capa HTTP

src/AppConfig.ts arma el Express:

  • helmet / CSP, CORS con allowlist, passport, i18n;
  • un interceptor propio anti-SQL-injection;
  • coerción de las date-strings del request a Date;
  • /health (superficial: no toca DB ni Redis) y /internal/heapsnapshot (localhost + token).

Los controllers usan decoradores de routing-controllers (@JsonController, @Get, @Body, @QueryParams, @Req…). Versionado por directorio y por path: controllers/v1 (31 áreas), v2 (patient), v3 (login, validación de OMEs), internal/workers. v2 y v3 son capas nuevas sobre v1, no rewrites.

Un controller que no está registrado no existe

Todo @JsonController tiene que estar en src/controllers/v1/ControllerImportsV1.ts — directo, o vía el getControllers() de su módulo. Si no está, la ruta no existe y no hay ningún error. Es el problema más común al agregar un endpoint.

Todos los controllers extienden SecuredController, que da el usuario de la request, el chequeo de permisos y el handler estándar de excepciones:

MétodoQué da
getUserId(req)req.userData.user_id. Es lo que se pasa a los services
getUser(req, level?)Un User con email, id, IP y nivel de seguridad
hasPermissionV2(code, req, qr?)El chequeo a usar. Valida el código de permiso. Si !success, se devuelve tal cual
hasPermission(role, req, qr?)Legacy: valida por rol. No usar en código nuevo
getHealthCenterId(req)El centro médico activo de la sesión
getUserAccess(req, qr?)Metadata completa del usuario (roles, unidades de gestión), cacheada en Redis
unhandledException(req, err)El catch estándar: loguea y devuelve un CustomResponse de error

Capa de servicios

services/v1|v2|v3, todos sobre la clase base Service. Se exportan como singletons (export default new XService()); dentro de los workers a veces se importan lazy (await import(...)) para bajar el costo de arranque.

Lo que hereda un service de Service:

MiembroPara qué
abstract getTarget()La entidad TypeORM que maneja. Lo único obligatorio de implementar
getQueryRunner()Un QueryRunner nuevo
getRepository(qr?, transaction?)Repositorio, respetando queryRunner y transacción activa
startTransaction / flushTransaction / rollBackTransaction / endTransaction(qr, response)El ciclo de transacción. endTransaction commitea o rollbackea según response.success
build del ORDER BYTraduce el sort del request a SQL, ignorando los campos no mapeados (whitelist)
loadUserInfo(user, qr?)Hidrata el usuario desde la base
exportRawQueryToExcel / exportQueryStreamToExcelExport a Excel por streaming (pg-query-stream), sin cargar todo en memoria
translate, getLocaleZonei18n y zona horaria

El patrón que se repite en todo el repo:

class MiService extends Service {
getTarget() { return MiEntidad; }

async guardar(dto: MiDTO): Promise<CustomResponse> {
const response = new CustomResponse();
const queryRunner = this.getQueryRunner();
try {
await this.startTransaction(queryRunner);
const repo = await this.getRepository(queryRunner, true);
// ...
response.ok(entidad);
} catch (e) {
response.error(e);
} finally {
await this.endTransaction(queryRunner, response); // commit/rollback según response.success
}
return response;
}
}
export default new MiService(); // singleton

Los services más pesados: PamiSIIServiceV3 (~4.800 líneas, el corazón del scraping), AtentionProcessService (~4.300, episodios), AuthorizationService, OmeManagementService, PatientService, ControlDashboardService y la familia Capita*Service.

Capa de datos

Entidades en src/schemas/** agrupadas por schema de Postgres, synchronize OFF. Ver Datos y persistencia.

Features nuevas

No se agregan al árbol plano de v1: van como módulo autocontenido en src/modules/*. Ver Módulos de feature.

Convenciones

npm run dev # API con nodemon + --inspect
npm run build # tsc → dist/ + copia de src/config a dist/
npm start # node dist/index.js
npm test # jest
npm run dev-<worker> # cada worker
  • Español en código, comentarios y logs. Comentá el por qué, y referenciá el issue (// ANF-435: …) cuando la decisión venga de un plan: es la convención más útil del repo para arqueología.
  • DTOs con class-validator. El CustomErrorHandler traduce sus errores a HTTP 400.
  • Fechas: Luxon DateTime en el código; base de datos y colas en UTC.
  • Nunca data cruda: ver Contrato de respuesta.

Tipado laxo, pero noUnusedLocals ON

strictNullChecks: false y noImplicitAny: false: el código es laxo. any y asunciones de no-null son normales — no las "arregles" de paso.

El error de compilación más frecuente

noUnusedLocals está ON: un import o una variable sin usar rompe el build.

Los alias de paths se declaran TRES veces

@config, @services, @controllers, @schemas, @dtos, @middlewares, @migrations, @utils, @modules, @appointments, @locales, @root

Agregar o renombrar un alias exige editar tres lugares y mantenerlos en sync:

  1. tsconfig.jsoncompilerOptions.paths (para tsc / ts-node),
  2. addAliases({...}) en src/AppConfig.ts (runtime module-alias, para el dist compilado),
  3. moduleNameMapper en jest.config.ts (para los tests).
Si te olvidás del (2), anda en dev y falla en producción

Y si te olvidás del (3), fallan los tests.

Deploy

deploy.sh hace un rolling deploy sin downtime de 4 containers de app detrás de nginx: espera que cada node-app-N esté Docker-healthy vía /health antes de seguir. Después reinicia los workers — esos no son rolling.

Orquestación en docker-compose.yml, imagen del DockerFile (Node 20-slim, node dist/index.js).

Gotchas

  • routing-controllers + @Res() sin cerrar la response = 404 fantasma. Pasó con el export a Excel: si streameás, cerrá la response.
  • Los loops de worker no envuelven todo en try/catch a propósito: dejan crashear el proceso para que Docker lo reinicie. No lo "arregles".
  • Los services son singletons: no guardes estado por request en propiedades de instancia.
  • La raíz del repo tiene basura de scratch (temp_sii_*.txt, dummydata/, un .jpg, startjob.txt, docker-compose.old.yml). No es parte de la app.