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.
- Middleware
x-api-key: 503 si el secreto no está configurado, 401 si no coincide (comparación timing-safe). - Abre
queryRunner+ transacción, delega enInboundService.apply(...). - Commit si el resultado es 2xx; rollback si es 4xx/5xx.
- El rollback del
catchsólo corre si la transacción sigue activa (evita enmascarar el error real con unTransactionNotStartedErrorcuando falla en/después del commit).
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:
aggregateType | Acción |
|---|---|
patient | InboundPatientService — alta/baja/modificación de paciente |
encounter | InboundEncounterService — proyección del episodio |
procedure | InboundProcedureService — práctica dentro de un episodio |
document | InboundDocumentService — documentación clínica |
authorization | No-op (200): la OP ya se resuelve dentro del episodio |
| otro | 422 "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:
- Empresa por
clientAppId(CompanyContextService). - Paciente vía
PATIENTLINK(nº externo). Si no existe → 422. - Idempotencia del episodio vía
EPISODELINK. - Baja (
delete/entered-in-error) →state = CLOSED+endDate. Fin. - Cobertura: lee
coverage.credential+coverage.plan. Sin esto → 422. - Plan vía
inbound.PLANMAP(companyId, planCode)→agreementId. Si no existe → 422. - Credencial (
PatientAgreement): busca porhealthcardnumbero la crea bajo el plan. - OP (
Authorization): si vieneauthorization.number, busca por código o crea una mínima. - Upsert de
AtentionProcess:patientAgreement(→PATIENTAGREEMENTID),sourceAuthorization(→SOURCEAUTHORIZATIONID),type(IMP→internación / AMB→ambulatorio), fechas, empresa, estado. InsertaEPISODELINKsi es nuevo y linkea la OP al episodio. - 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:
- Empresa por
clientAppId. - Baja (
delete/entered-in-error) víaPROCEDURELINK→state = DELETED. Fin. - Episodio requerido:
Procedure.encounter→EPISODELINK. Sinencounter(ambulatoria) → 422. Episodio inexistente → 422. - Práctica (nomenclador): resuelve el
ElementdesdeProcedure.code. El cliente puede mandar código (coding.code), descripción (code.text/coding.display) o ambos. Orden: override eninbound.PRACTICEMAP (companyId, practiceKey)(keyed por código o descripción) → si no, nomenclador directo (ELEMENTS.CODEexacto /ELEMENTS.DESCRIPTIONexacto, case-insensitive). No resuelve → 422 (descripción con varios matches → 422 "ambiguo"). - Idempotencia vía
PROCEDURELINK. - Upsert de
PatientSupplies:atentionProcess,patient(del episodio),element,amount(default 1),startingDate/endDate(deperformedDateTime/performedPeriod),authorizationCode(de la extensiónauthorization),companyId/healthCenterIdheredados del episodio,state = CREATED. InsertaPROCEDURELINKsi es nuevo. - EventLog userless.
Proyección de documentación
InboundDocumentService proyecta un DocumentReference a finances.DOCUMENTS + el binario en
finances.FILES (reusando FileService):
- Empresa por
clientAppId. - Baja (
delete/entered-in-error) víaDOCUMENTLINK→active = false. Fin. - Target polimórfico de
subject.type+subject.identifier:Patient→PATIENTLINK,Encounter→EPISODELINK,Procedure→PROCEDURELINK→ENTITYID+ENTITYCODE+PATIENTID. Si no resuelve → 422. - Binario inline:
content[0].attachment.data(base64) → Buffer →FileService.saveBuffer(escribe a disco + filaFILES). Sindata→ 422. - Tipo (
DocumentType) best-effort por nombre; nullable si no matchea. - Upsert de
Document(fileId,documentTypeId?, target,patientId,description,companyId,active). InsertaDOCUMENTLINKsi es nuevo. - EventLog userless.
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.
| Tabla | Mapea | Clave |
|---|---|---|
inbound.PATIENTLINK | nº externo de paciente → customer.PATIENTS.ID | (COMPANYID, EXTERNALPATIENTNUMBER) |
inbound.EPISODELINK | nº externo de episodio → finances.ATENTIONPROCESS.ID | (COMPANYID, EXTERNALENCOUNTERNUMBER) |
inbound.PROCEDURELINK | nº externo de práctica → finances.PATIENTSUPPLIES.ID | (COMPANYID, EXTERNALPROCEDURENUMBER) |
inbound.DOCUMENTLINK | nº externo de documento → finances.DOCUMENTS.ID | (COMPANYID, EXTERNALDOCUMENTNUMBER) |
inbound.PLANMAP | planCode del cliente → configuration.AGREEMENTS.ID | (COMPANYID, PLANCODE) |
inbound.PRACTICEMAP | có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. CubrePOST /fhir→ outbox, incluyendo que la extensióncoveragesobrevive verbatim en el payload, que elauthorizationse emite como agregado aparte, que la práctica preservacode/encounter, y que el documento viaja conattachment.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.