Ir al contenido

Arquitectura

La arquitectura de EMA Clinic combina tres decisiones que se condicionan mutuamente: ejecutar todo el backend en el edge de Cloudflare, modelar la información clínica como recursos FHIR canónicos en Aidbox, y delegar el resto del estado operacional a Supabase con RLS estricta. El resultado es un sistema donde la SPA React habla con un único worker Hono que orquesta los dos backends de datos según el tipo de información que esté manejando.

El gráfico siguiente muestra cómo se conectan las piezas. La SPA ejecuta en CF Pages como sitio estático, las APIs viven en un worker independiente, y entre ambos opera una cadena de middleware que resuelve auth, tenant, cliente FHIR per-tenant, RBAC y audit antes de invocar al handler:

Backends

Middleware

Cloudflare Edge

Cliente

fetch + Bearer

serves

API calls

proxy

async

React 19 SPA

Vite + Tailwind v4 + DS v1.0

CF Pages

SPA estática

CF Worker · Hono

53 routes

authMiddleware

Bearer JWT Supabase

tenantMiddleware

user_roles → tenantId

fhirMiddleware

buildFhirClientForTenant

auditLog

AuditEvent + audit_logs

requireRole

RBAC gate

Aidbox FHIR R4

ORGBAC scoped

per-tenant credentials

Supabase emashared

tenants · user_roles ·

tenant_aidbox_credentials

Supabase emaclinic

operational data

RLS per tenant

emi.emahealth.io

EMI proxy

emadte worker

SII DTE

emafhir SDK

0.6.0

Las flechas hacia los servicios externos (emi.emahealth.io y el worker emadte) muestran integraciones específicas. EMI opera como proxy server-side para que el frontend nunca conozca la API key del asistente; emadte recibe las peticiones de emisión DTE de forma asincrónica para no bloquear el flujo clínico.

La organización del repo refleja la separación entre middleware (transversal a todas las rutas), librerías de dominio (motores puros sin DB) y handlers de API. El cliente vive en src/client/ con sub-carpetas por área funcional:

emaclinic/
├── src/
│ ├── index.ts # Hono entry — middleware chain + 53 routes
│ ├── env.d.ts # Cloudflare Env bindings
│ ├── api/ # 53 route handlers
│ ├── middleware/
│ │ ├── auth.ts # Bearer JWT validation
│ │ ├── tenant.ts # user_roles → tenantId resolution
│ │ ├── tenant-subdomain.ts # tenant from subdomain
│ │ ├── fhir.ts # per-tenant EmaFhirClient (post-S9)
│ │ ├── patient-auth.ts # OTP patient auth (portal)
│ │ ├── rbac.ts # role-based access control
│ │ ├── audit.ts # AuditEvent + audit_logs
│ │ └── security-headers.ts # CSP, X-Frame-Options
│ ├── lib/
│ │ ├── tenant-fhir.ts # buildFhirClientForTenant()
│ │ ├── crypto.ts # AES-GCM decrypt
│ │ ├── supabase-server.ts # createSupabaseClient (schema-scoped)
│ │ ├── ges-evaluator.ts # GES pattern matching (90 patologías)
│ │ ├── eno-evaluator.ts # ENO detection (150 códigos)
│ │ ├── condition-evaluator.ts # Rules engine clinical
│ │ ├── careplan-conditions.ts # Declarative ConditionGroups
│ │ ├── billing/copago-engine.ts # Fonasa copago
│ │ ├── engines/ # Urgencia (ESI suggester, metrics)
│ │ ├── rem/ # DEIS REM export
│ │ ├── sdc/ # Structured Data Capture helpers
│ │ ├── scheduling/ # Multi-recurso engine + tests
│ │ └── ...
│ └── client/
│ ├── App.tsx # React Router + lazy pages
│ ├── pages/ # 45 páginas (lazy-loaded)
│ ├── components/
│ │ ├── admin/ # Org setup, prestaciones, convenios
│ │ ├── billing/ # Invoice, CashRegister, Reconciliation
│ │ ├── clinical/ # ConditionBuilder, OrderEntry, PatientBanner
│ │ ├── layout/ # Layout, Breadcrumb, CommandPalette
│ │ ├── scheduling/ # ScheduleView, BookingWizard
│ │ └── ui/ # Radix-based primitives
│ ├── stores/ # Zustand (fichaStore, authStore)
│ ├── lib/
│ │ ├── auth.tsx # AuthContext + PERMISSION_MAP
│ │ ├── api.ts # apiFetch (Bearer auto-inject)
│ │ ├── supabase.ts # singleton client
│ │ └── fhir.ts # FHIR types
│ └── styles/
│ ├── design-system/ # tokens 3-layer
│ └── print.css
└── supabase/migrations/ # SQL migrations (apply_migration)

Las dos sub-carpetas que más cambios reciben en cada sprint son api/ (handlers nuevos cuando se incorpora funcionalidad) y lib/engines/ (motores puros sin acceso a DB que se testean en isolación).

El sprint S9 cambió la forma de hacer multi-tenancy en el producto. Antes existía un cliente FHIR global y la separación entre tenants se aplicaba con filtros TypeScript, lo que dejaba al sistema expuesto a errores de developer si alguien olvidaba aplicar el filtro o lo aplicaba incorrectamente. La nueva aproximación es per-tenant FHIR client decryptado en runtime: cada request construye el cliente con las credenciales propias del tenant, y Aidbox aplica ORGBAC server-side sobre la URL.

Aidboxemafhircrypto.ts (AES-GCM)fhirMiddleware(emashared)tenantMiddlewareauthMiddlewareClienteAidboxemafhircrypto.ts (AES-GCM)fhirMiddleware(emashared)tenantMiddlewareauthMiddlewareClientehandler usa c.get("fhir")Bearer JWT + X-Tenant-Slug1validar JWT (Supabase)2user.id resuelto3SELECT user_roles WHERE user_id, tenant_id4{ tenantId, role }5c.set("tenantId", role)6SELECT tenant_aidbox_credentials WHERE tenant_id7{ client_id, secret_ciphertext, key_version }8decrypt(secret_ciphertext, AIDBOX_SECRETS_ENCRYPTION_KEY_v_n_)9client_secret en claro10createEmaClient({ tenant, supabase })11EmaFhirClient (cache 5 min in-memory)12c.set("fhir", client)13GET Organization/{tenantId}/fhir/Patient14Patient[] (scoped server-side)15

Hay cuatro consecuencias prácticas que merecen atención al leer el diagrama. Primero, el aislamiento es server-side: aunque un filtro TypeScript falle en el código del worker, Aidbox rechaza queries cross-tenant porque la URL ya está scoped a la Organization correcta y la policy server-side lo verifica. Segundo, el modelo opera con defensa en profundidad: ORGBAC al nivel de URL, RLS sobre las tablas operacionales en Supabase, y el tagging explícito por meta.tag = urn:ema:tenant|<id> en cada recurso FHIR forman tres capas independientes que se refuerzan.

Tercero, el cache de cinco minutos del EmaFhirClient evita pagar el costo de una query a tenant_aidbox_credentials por cada request: el cliente queda en memoria del Worker isolate hasta que expira el TTL o el isolate se descarta. Y cuarto, la clave maestra está versionadaAIDBOX_SECRETS_ENCRYPTION_KEY_V1, _V2 y siguientes— lo que permite rotar la clave de forma incremental sin tener que re-encriptar todos los secrets en bloque.

No todo el estado del producto es información clínica, y forzar todo a vivir en FHIR habría sido contraproducente. La regla de diseño es clara: lo clínico va a Aidbox, todo lo demás vive en Supabase. La tabla siguiente resume cómo queda repartido en la práctica:

CategoríaSistemaRazón
Patient, Encounter, Condition, Observation, ServiceRequest, DiagnosticReport, Appointment, Schedule, Slot, Invoice, etc.Aidbox FHIREstándar clínico, interoperabilidad, regulatorio, auditable
tenants, user_roles, tenant_config, tenant_aidbox_credentialsSupabase emasharedConfiguración multi-tenancy, secrets, staff
cash_registers, cash_movements, cash_reconciliationSupabase emaclinicOperación de caja, no clínico
schedule_patterns (RRULE), schedule_overrides, scheduling_exceptionsSupabase emaclinicFHIR Schedule no soporta RRULE nativo
service_charge_items, pricing_agreements, agreement_pricesSupabase emaclinicConfiguración de pricing, no recurso clínico
audit_logsSupabase emaclinicPersistencia rápida, no bloquea el flujo
invoice_encounter (FK)Supabase emaclinicPuente entre Invoice (FHIR) y Encounter (FHIR)

La tabla invoice_encounter merece comentario aparte: actúa como puente FK entre dos recursos FHIR (Invoice y Encounter) que no tienen una relación directa expresable en el modelo FHIR puro. Es el tipo de detalle que justifica tener Supabase al lado de Aidbox en lugar de empujar todo al modelo FHIR.

El orden de la cadena de middleware en src/index.ts no es arbitrario: cada nivel asume que los anteriores ya hicieron su trabajo. Si se reordenan, el sistema deja de funcionar correctamente.

1. cors()
2. securityHeaders() // CSP, X-Frame-Options
3. authMiddleware // Bearer JWT validation
4. tenantMiddleware // user_roles → tenantId + role
5. fhirMiddleware // EmaFhirClient per-tenant
6. auditLog // AuditEvent + audit_logs
↓ luego cada handler aplica:
7. requireRole(...) // RBAC gate, 403 si no allowlist

authMiddleware debe correr antes que tenantMiddleware porque este último necesita el user.id resuelto del JWT. fhirMiddleware necesita conocer el tenantId para construir el cliente per-tenant. auditLog corre al final del flujo común porque depende de saber qué tenant, qué usuario y qué recurso están siendo afectados. La verificación RBAC se aplica por handler, no globalmente, porque cada endpoint tiene su propio allowlist de roles.

Flujo crítico: encuentro ambulatorio completo

Sección titulada «Flujo crítico: encuentro ambulatorio completo»

El diagrama siguiente sintetiza el flujo más usado del producto, el encuentro ambulatorio que va desde admisión hasta DTE emitido:

Paciente llega

Secretaria · admisión

POST /api/encounters

Encounter status=arrived

Cajero · cobro

POST /api/caja/movements

Invoice draft

+ invoice_encounter FK

Encounter in-progress

Médico · /encuentros/:id

Ficha clínica

Evaluación auto:

conditions · GES · ENO

Condition provisional

Tasks: GES check · SDC · ENO

Submit SDC form

POST /api/questionnaire-responses

extract.ts genera

Observation · DocumentReference · Consent

Médico confirma

verificationStatus=confirmed

Diagnóstico final + prescripciones

PATCH /api/encounters/:id

status=finished

DocumentReference

discharge-summary

Async: DTE SII

emadte worker

Boleta 39 emitida

Invoice issued

Tres detalles del flujo conviene destacar. El Encounter cambia de estado tres veces —arrived al admitirse, in-progress al inicar consulta, finished al cerrarla— y cada transición dispara efectos secundarios (creación de tasks, evaluación de Conditions, generación del discharge summary). La emisión DTE es asincrónica: el worker de emadte recibe la petición pero no bloquea el cierre del encuentro, lo que evita que un problema con SII frene la operación clínica. Y el discharge summary se genera automáticamente cuando el médico confirma el cierre, con firma QR y código de verificación público.

El SPA usa React Router con lazy() en todas las páginas, lo que mantiene el bundle inicial bajo y carga progresivamente cada vista solo cuando se accede. El Design System v1.0 vive en src/client/styles/design-system/ con tres capas: primitives (hex colors, opacity tokens), themes (light, dark, dimmed y well, cada uno remapeando ~50 tokens semánticos) y components. Existe también un print.css con reglas @media print específicas para órdenes, recetas y discharge summaries, lo que permite imprimir documentos clínicos sin estilos de pantalla. El CommandPalette provee un atajo global Cmd+K para navegación rápida y ejecución de acciones sin tocar el sidebar.