Skip to main content

El endpoint /inbound (proteus-backend)

Es la contraparte del despachador del gateway: recibe cada mensaje y lo proyecta al dominio real de Proteus. Vive como módulo autocontenido en src/modules/inbound (patrón del módulo appointments), registrado en ControllerImportsV1.

Adapter (lado gateway)

El HttpProteusAdapter del gateway envía a POST {PROTEUS_BASE_URL}/inbound el sobre:

{
"aggregateType": "encounter",
"aggregateId": 123,
"operation": "create",
"payload": { "aggregateType": "encounter", "resource": { /* recurso FHIR */ } },
"clientAppId": "proteus-gateway"
}

Con el header x-api-key (secreto compartido). El clientAppId es lo que hace el ruteo multi-tenant en el backend.

Controller

InboundController (@Post("/inbound")) es fino: sólo maneja HTTP + transacción.

  1. Middleware x-api-key: 503 si el secreto no está configurado, 401 si no coincide (comparación timing-safe).
  2. Abre queryRunner + transacción, delega en InboundService.apply(...).
  3. Commit si el resultado es 2xx; rollback si es 4xx/5xx.
  4. El rollback del catch sólo corre si la transacción sigue activa (evita enmascarar el error real con un TransactionNotStartedError cuando falla en/después del commit).
Este endpoint devuelve status HTTP real

A propósito rompe la convención del resto del backend (que responde 200 + {success:false}): el gateway interpreta el status. 2xx = ok, 4xx = denied (sin reintento), 5xx = reintento. Ver Flujo inbox/outbox.

Ruteo por aggregateType

InboundService despacha al servicio de proyección según el tipo de agregado:

aggregateTypeAcción
patientInboundPatientService — alta/baja/modificación de paciente
encounterInboundEncounterService — proyección del episodio
procedureInboundProcedureService — práctica dentro de un episodio
documentInboundDocumentService — documentación clínica
authorizationNo-op (200): la OP ya se resuelve dentro del episodio
otro422 "not implemented"

Por qué authorization es no-op

El gateway emite un agregado authorization suelto (además del encounter), pero en Proteus la OP viaja dentro del Encounter (extensión authorization) y la resuelve InboundEncounterService. El mensaje suelto es redundante para el backend: se acepta y descarta (200) para no ensuciar el outbox con denied. El acoplamiento queda del lado de Proteus (que es específico), no del gateway (que es genérico). La práctica ya resuelve su propia OP desde su extensión; cuando se ingeste turno con OP propia, esa rama seguirá el mismo patrón.

Proyección de paciente

InboundPatientService correlaciona por número externo vía inbound.PATIENTLINK (por empresa), sin tocar la identidad de customer.PATIENTS. El DNI/beneficiario se guardan como atributos. operation:"delete" o active:false = baja lógica.

Proyección de episodio

InboundEncounterService es el flujo central:

  1. Empresa por clientAppId (CompanyContextService).
  2. Paciente vía PATIENTLINK (nº externo). Si no existe → 422.
  3. Idempotencia del episodio vía EPISODELINK.
  4. Baja (delete / entered-in-error) → state = CLOSED + endDate. Fin.
  5. Cobertura: lee coverage.credential + coverage.plan. Sin esto → 422.
  6. Plan vía inbound.PLANMAP (companyId, planCode)agreementId. Si no existe → 422.
  7. Credencial (PatientAgreement): busca por healthcardnumber o la crea bajo el plan.
  8. OP (Authorization): si viene authorization.number, busca por código o crea una mínima.
  9. Upsert de AtentionProcess: patientAgreement (→ PATIENTAGREEMENTID), sourceAuthorization (→ SOURCEAUTHORIZATIONID), type (IMP→internación / AMB→ambulatorio), fechas, empresa, estado. Inserta EPISODELINK si es nuevo y linkea la OP al episodio.
  10. Registra un EventLog (sin usuario) por cada operación.

Proyección de práctica

InboundProcedureService proyecta un Procedure a finances.PATIENTSUPPLIES (la prestación realizada del episodio). Sólo dentro de episodio:

  1. Empresa por clientAppId.
  2. Baja (delete / entered-in-error) vía PROCEDURELINKstate = DELETED. Fin.
  3. Episodio requerido: Procedure.encounterEPISODELINK. Sin encounter (ambulatoria) → 422. Episodio inexistente → 422.
  4. Práctica (nomenclador): resuelve el Element desde Procedure.code. El cliente puede mandar código (coding.code), descripción (code.text/coding.display) o ambos. Orden: override en inbound.PRACTICEMAP (companyId, practiceKey) (keyed por código o descripción) → si no, nomenclador directo (ELEMENTS.CODE exacto / ELEMENTS.DESCRIPTION exacto, case-insensitive). No resuelve → 422 (descripción con varios matches → 422 "ambiguo").
  5. Idempotencia vía PROCEDURELINK.
  6. Upsert de PatientSupplies: atentionProcess, patient (del episodio), element, amount (default 1), startingDate/endDate (de performedDateTime/performedPeriod), authorizationCode (de la extensión authorization), companyId/healthCenterId heredados del episodio, state = CREATED. Inserta PROCEDURELINK si es nuevo.
  7. EventLog userless.

Proyección de documentación

InboundDocumentService proyecta un DocumentReference a finances.DOCUMENTS + el binario en finances.FILES (reusando FileService):

  1. Empresa por clientAppId.
  2. Baja (delete / entered-in-error) vía DOCUMENTLINKactive = false. Fin.
  3. Target polimórfico de subject.type + subject.identifier: PatientPATIENTLINK, EncounterEPISODELINK, ProcedurePROCEDURELINKENTITYID + ENTITYCODE + PATIENTID. Si no resuelve → 422.
  4. Binario inline: content[0].attachment.data (base64) → Buffer → FileService.saveBuffer (escribe a disco + fila FILES). Sin data422.
  5. Tipo (DocumentType) best-effort por nombre; nullable si no matchea.
  6. Upsert de Document (fileId, documentTypeId?, target, patientId, description, companyId, active). Inserta DOCUMENTLINK si es nuevo.
  7. EventLog userless.
Usuario de integración

FileService.saveBuffer y el Document derivan registerUser de un User. La ingesta es userless, así que se usa un usuario de integración configurable (env GATEWAY_INBOUND_USER_ID); si no se setea, registerUser queda null. El binario se escribe bajo BASE_UPLOAD_PATH.

Tablas de mapeo (schema inbound)

La correlación gateway↔dominio se aísla en un schema propio, sin tocar las tablas del core. FKs lógicas (sin constraint), nombres en MAYÚSCULA.

TablaMapeaClave
inbound.PATIENTLINKnº externo de paciente → customer.PATIENTS.ID(COMPANYID, EXTERNALPATIENTNUMBER)
inbound.EPISODELINKnº externo de episodio → finances.ATENTIONPROCESS.ID(COMPANYID, EXTERNALENCOUNTERNUMBER)
inbound.PROCEDURELINKnº externo de práctica → finances.PATIENTSUPPLIES.ID(COMPANYID, EXTERNALPROCEDURENUMBER)
inbound.DOCUMENTLINKnº externo de documento → finances.DOCUMENTS.ID(COMPANYID, EXTERNALDOCUMENTNUMBER)
inbound.PLANMAPplanCode del cliente → configuration.AGREEMENTS.ID(COMPANYID, PLANCODE)
inbound.PRACTICEMAPcódigo o descripción del cliente (override) → configuration.ELEMENTS.ID(COMPANYID, PRACTICEKEY)

Tests

  • Gateway: suite e2e (pnpm test:e2e:local) contra Postgres/Redis reales, con Proteus mockeado. Cubre POST /fhir → outbox, incluyendo que la extensión coverage sobrevive verbatim en el payload, que el authorization se emite como agregado aparte, que la práctica preserva code/encounter, y que el documento viaja con attachment.data (base64) intacto.
  • Backend: suite jest en tests/integration/inbound/ (patrón del repo: supertest en el borde + EntityManager/servicios mockeados, sin DB). Cubre el controller (api-key 503/401, commit/rollback por status, DTO) y cada service de proyección (paciente, episodio, práctica, documento) con sus caminos felices y todos los 422. Correr: npx jest tests/integration/inbound.