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
src/index.ts es la API y es cada worker. El rol lo elige la variable de entorno
EXECUTION_MODE.
EXECUTION_MODE | Rol |
|---|---|
vacío o normal | API HTTP, clusterizada por cores |
sii-worker* | new SIIWorkerManager().start() — el scraping del SII |
cup-worker* | runWorkFromCupV2() — el circuito de OMEs |
| cualquier otro | main() 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.
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étodo | Qué 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:
| Miembro | Para 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 BY | Traduce el sort del request a SQL, ignorando los campos no mapeados (whitelist) |
loadUserInfo(user, qr?) | Hidrata el usuario desde la base |
exportRawQueryToExcel / exportQueryStreamToExcel | Export a Excel por streaming (pg-query-stream), sin cargar todo en memoria |
translate, getLocaleZone | i18n 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
CustomErrorHandlertraduce sus errores a HTTP400. - Fechas: Luxon
DateTimeen 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.
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:
tsconfig.json→compilerOptions.paths(paratsc/ts-node),addAliases({...})ensrc/AppConfig.ts(runtimemodule-alias, para eldistcompilado),moduleNameMapperenjest.config.ts(para los tests).
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.