Ir al contenido

Arquitectura

La arquitectura de EMA Lab combina tres decisiones que se condicionan mutuamente: ejecutar todo el backend en el edge de Cloudflare, modelar los datos clínicos como recursos FHIR canónicos en Aidbox con aislamiento ORGBAC, y delegar el resto del estado operacional a Supabase con RLS estricta. Sobre eso opera un Hub Docker on-premise opcional para conectar analizadores físicos al cloud sin requerir internet directo en el sitio del laboratorio. Esta página recorre los componentes principales, la cadena de middleware, los conectores inbound y outbound, y los patrones cross-cutting que aparecen en todos los handlers.

On-premise (cliente)

Servicios externos

Aidbox FHIR R4

Supabase emahub

Cloudflare Worker (Hono)

Cliente (browser)

fetch /api/*

X-API-Key

/api/emi/proxy

emails + DLQ

hub-token

ASTM/HL7v2

React 19 SPA

Vite + Router 7 + Tailwind 4

Design System v1.0

4 themes · Radix wrappers

authMiddleware

JWT Supabase

tenantMiddleware

resuelve user_roles

clientIsolationMiddleware

filtro client access

38 módulos API

orders · samples · results · ...

Cron: retry-pending */5min

stewardship trimestral

emashared

tenants · users · client_config

emalab

62 base tables

RLS por tenant_id

Organization tenant A

Organization tenant B

ORGBAC server-side

/Organization/id/fhir/*

emadte worker

facturación electrónica

emi.emahealth.io

AI assistant

Loops.so

email + DLQ alerts

Hub Docker

4 connection modes

Analizador

ASTM · HL7v2

src/client/
├── design-system/ # Tokens CSS + componentes DS (IconBox, Avatar, etc.)
│ ├── primitives.css # hex colors (NO referenciar desde UI)
│ ├── themes/*.css # 4 themes con ~50 semantic tokens
│ └── components/ # IconBox, Avatar, VitalPill, AlertCard, StatusDot, etc.
├── components/
│ ├── layout/ # AppLayout, Sidebar, Header
│ └── ui/ # 10 wrappers Radix (L027) — Dialog, Popover, Select, Tabs, etc.
├── pages/ # 35+ pages
│ ├── admin/ # Admin dashboard, audit, FHIR emission status
│ ├── orders/ # Lista + detalle + nueva orden
│ ├── checkin/ # Recepción de muestras + lote
│ ├── catalog/ # Test catalog, profiles, specimens
│ ├── worklist/ # Cola técnica por sección
│ ├── results/ # ManualEntry inline + EMI feedback
│ ├── validation/ # Stage 1-4 gate + cascade
│ ├── reports/ # Lista + detalle DR + verify QR
│ ├── micro/ # MicroCultures + MicroCultureDetail + MicroConfig
│ ├── inventory/ # InventoryStock + Consumption + Alerts
│ ├── qc/ # QC lots, Westgard charts, Bull's
│ ├── billing/ # Price lists, invoices, DTE
│ ├── connectors/ # HL7v2 / FHIR Subs admin
│ ├── nonconformities/ # CLSI GP41 16 reasons
│ ├── portal/ # Paciente verify + results
│ └── settings/ # Profile, organization, users, sections
├── lib/
│ ├── auth.ts # Supabase auth wrapper
│ ├── api.ts # apiFetch helper
│ ├── client-context.tsx # Active client filter
│ ├── barcode.ts # Code 128B SVG (pure TS)
│ └── qr.ts # Minimal QR Code SVG (demo-quality)
└── styles/
└── globals.css # Tailwind v4 + DS tokens
src/
├── api/ # 38 módulos Hono
│ ├── auth.ts # /auth/me · /auth/resolve-tenant · /auth/callback
│ ├── orders.ts # /api/orders/* (lab_admin, lab_reception, lab_tech)
│ ├── samples.ts # /api/samples/* (+ lab_phlebotomist)
│ ├── checkin.ts # /api/checkin/* (idem)
│ ├── results.ts # /api/results/* (+ lab_director)
│ ├── validation.ts # /api/validation/* (4 stages)
│ ├── worklists.ts # /api/worklists/*
│ ├── reports.ts # /api/reports/* (incluye lab_viewer)
│ ├── micro.ts # /api/micro/* (cultures + antibiograma)
│ ├── molecular.ts # /api/molecular/* (parser registry + sign)
│ ├── stewardship.ts # /api/micro/stewardship/* (CLSI M39)
│ ├── inventory.ts # /api/inventory/* (stock + auto-consume)
│ ├── qc.ts # /api/qc/* (Westgard + Bull's)
│ ├── billing.ts # /api/billing/* (price lists, DTE)
│ ├── connectors.ts # /api/connectors/* (HL7v2 + FHIR Subs)
│ ├── hub.ts # /api/hub/* (token-auth, on-prem)
│ ├── platform.ts # /api/platform/* (X-Platform-Key)
│ ├── portal.ts # /api/portal/* (token paciente)
│ ├── emi.ts # /api/emi/{chat,access} proxy
│ └── ... (catalog, sections, clients, users, config, tat, dashboard,
│ nonconformities, sla, equipment, maintenance, routing,
│ transport, exclusions, lab-sites, barcode-config,
│ settings-{profile,users,organization,data})
├── lib/
│ ├── fhir-server.ts # fhirFetch helper con per-tenant ORGBAC
│ ├── tenant-fhir.ts # buildEmaFhirClient (opt-in, L102)
│ ├── supabase-server.ts # Server client per-request
│ ├── micro.ts # Pure helpers — stage transitions, FHIR builders, timeouts
│ ├── inventory.ts # FIFO selection, alerts, expiry
│ ├── reports/finalize.ts# applyFinalizationPatch idempotente
│ ├── stewardship/ # Engine M39 + writer + DocumentReference
│ ├── molecular/ # Parser registry + critical targets
│ ├── hl7v2/ # Inline parser + outbound builder
│ ├── connectors/ # FHIR forward + retry queue
│ └── engines/ # qc, emi-evaluator, autoverify, validation-cascade
├── middleware/
│ ├── auth.ts # Valida JWT Supabase
│ ├── tenant.ts # Resuelve tenant_id desde user_roles (L002)
│ ├── client-isolation.ts# Filtra clientes según user_client_access
│ └── rate-limit.ts # KV-based rate limiting (L086)
└── index.ts # Hono app entry · mount order · cron handlers

Desde el sprint S9 (commit 82a07d4) todas las queries FHIR pasan por la URL /Organization/{tenantId}/fhir/*, lo que permite a Aidbox rechazar las queries cross-tenant server-side independientemente del filtro client-side. La consecuencia arquitectónica es relevante: un bug de developer que olvide aplicar un filtro no compromete el aislamiento, porque la URL ya está scoped a la Organization correcta antes de que la query llegue a Aidbox.

AidboxfhirFetch helpertenantMiddlewareWorker (Hono)React SPAAidboxfhirFetch helpertenantMiddlewareWorker (Hono)React SPAGET /api/ordersAuthorization: Bearer JWT1authMiddleware(JWT) ok2SELECT tenant_idFROM user_roles WHERE user_idcaller JWT (no service role) — L0993c.set('tenantId', tenant.id)4fhirFetch(env, '/ServiceRequest', opts, tenantId)5SELECT tenant_aidbox_credentialsWHERE tenant_id6AES-GCM decrypt OAuth secret7GET /Organization/{tenantId}/fhir/ServiceRequestAuthorization: Bearer (tenant OAuth)8200 ServiceRequest[]9data10JSON11

El aislamiento entre laboratorios opera en tres capas independientes que se refuerzan mutuamente. La primera es el ORGBAC server-side de Aidbox: la URL define el tenant y no se negocia. La segunda es la RLS de Supabase, que aplica tenant_id = auth.tenant_id sobre todas las tablas operacionales. La tercera es el clientIsolationMiddleware, que filtra por user_client_access para usuarios con visibilidad acotada a ciertos clientes externos —típicamente un TM que solo procesa muestras de un subconjunto de las clínicas que envían órdenes al laboratorio.

tech_section_auth

No

emi-evaluator engine

fail

warn / ok

autoverify-conditions

No

Resultado ingresado

ManualEntry o Hub

Stage 1

técnica

Auth TM válida

para sección?

Reject + audit

Stage 2

EMI evaluator

EMI flag

warn / fail?

Hold con razón

requires director

Stage 3

autoverify

Cumple

condiciones?

Status final

auto

Stage 4

firma director

DR signed + QR

verify code público

El pipeline de validación opera en cuatro etapas consecutivas que todos los resultados —incluso los que terminan en autoverify— deben atravesar. La primera etapa verifica la autorización del TM para operar sobre la sección donde el resultado entra; la segunda corre el evaluador EMI con las reglas configuradas; la tercera intenta liberar el resultado automáticamente si cumple las condiciones de autoverify del test; la cuarta requiere firma del director cuando ninguna etapa anterior liberó el resultado.

Lab

Inbound

HL7v2 ORM

FHIR Subscription

Retry

connector_type

connector_type

Cron */5min

processRetryQueue

FHIR outbound

HL7v2 outbound

Outbound

DR firmado

builder ORU R01

+ retry queue

EHR cliente

HL7v2 ACK

EHR cliente

POST /api/connectors/

hl7v2/inbound

API key SHA-256 hash

Aidbox externo

POST /api/connectors/

fhir/inbound

parser HL7v2 inline

ServiceRequest creado

El producto soporta cuatro tipos de comunicación con sistemas externos. Inbound HL7v2 recibe mensajes ORM con autenticación basada en API key con SHA-256 hash at rest; inbound FHIR recibe órdenes desde Aidbox externos vía Subscription estándar. Outbound HL7v2 emite ORU R01 al EHR del cliente cuando el reporte queda firmado, y outbound FHIR forward bundles cuando corresponda. Un cron de retry cada cinco minutos procesa la cola de pendientes para ambos protocolos outbound, lo que mantiene el sistema robusto frente a caídas momentáneas del lado del cliente.

Algunos laboratorios operan analizadores físicos en sus sites que no tienen acceso directo a Internet, ya sea por política corporativa o por limitaciones de los protocolos legacy ASTM y HL7v2 que muchos equipos hablan nativamente. Para esos casos el producto provee un Hub Docker que se instala en la red local del site y actúa de intermediario, con cuatro modos de conexión (decisión ADR-017) —polling, SSE-keepalive, hybrid y websocket— que permiten operar bajo prácticamente cualquier configuración de firewall.

Site del laboratorio

EMA Lab Cloud (CF Worker)

long-poll

/api/hub/worklists

POST /api/hub/results

+ auto-consume inventory

heartbeat 60/min

TCP/MLLP

TCP/MLLP

api/hub/*

hub-token auth

emalab.hub_registry

+ tokens

Hub Docker

Node 22

Analyzer Roche

ASTM

Analyzer Siemens

HL7v2

Logs locales

+ retry buffer

Tres crons corren periódicamente para mantener el sistema operativo sin intervención manual:

CronScheduleFunción
retry-pending*/5 * * * *Procesa connector_retry_queue (FHIR y HL7v2 outbound)
stewardship-quarterly0 3 1 1,4,7,10 *Engine CLSI M39 que produce reportes firmables (L049, L074)
fhir-emission-retry*/15 * * * *Re-emite los DetectedIssue de stewardship pendientes (L079)

Cinco principios atraviesan toda la arquitectura. El primero son los builders puros en src/lib/**/*.ts: funciones sin acceso a base de datos o FHIR que pueden testearse en isolación. El segundo es el patrón de dual-write FHIR: Supabase como fuente primaria operacional y Aidbox como fuente canónica, con tres columnas que trackean el estado de emisión (fhir_emission_status, fhir_emission_error, fhir_emission_at) más un retry sweep que re-emite lo pendiente (L071).

El tercer principio es el provider interface con Mock, Http y resolver: cada servicio externo opcional (FEA, IMED, Loops, emadte) tiene una implementación mock para tests más una HTTP para producción, y un resolver que decide cuál usar según la configuración del tenant. El never-throw es regla: ningún provider opcional debe poder romper el flujo principal. El cuarto es el sprint entry audit, un bloque cero obligatorio en cada sprint que valida los P0 del audit previo contra el código actual antes de empezar trabajo nuevo. Y el quinto es el R2 binding unificado con DOCS_BUCKET cross-repo (clinic más lab), que evita la proliferación de bindings ad-hoc y simplifica la rotación de credenciales.