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.
Vista general de capas
Sección titulada «Vista general de capas»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:
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.
Estructura de carpetas
Sección titulada «Estructura de carpetas»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).
Multi-tenancy post-S9 ORGBAC
Sección titulada «Multi-tenancy post-S9 ORGBAC»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.
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á
versionada —AIDBOX_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.
Patrón híbrido FHIR + Supabase
Sección titulada «Patrón híbrido FHIR + Supabase»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ía | Sistema | Razón |
|---|---|---|
| Patient, Encounter, Condition, Observation, ServiceRequest, DiagnosticReport, Appointment, Schedule, Slot, Invoice, etc. | Aidbox FHIR | Estándar clínico, interoperabilidad, regulatorio, auditable |
tenants, user_roles, tenant_config, tenant_aidbox_credentials | Supabase emashared | Configuración multi-tenancy, secrets, staff |
cash_registers, cash_movements, cash_reconciliation | Supabase emaclinic | Operación de caja, no clínico |
schedule_patterns (RRULE), schedule_overrides, scheduling_exceptions | Supabase emaclinic | FHIR Schedule no soporta RRULE nativo |
service_charge_items, pricing_agreements, agreement_prices | Supabase emaclinic | Configuración de pricing, no recurso clínico |
audit_logs | Supabase emaclinic | Persistencia rápida, no bloquea el flujo |
invoice_encounter (FK) | Supabase emaclinic | Puente 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.
Cadena de middleware
Sección titulada «Cadena de middleware»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-Options3. authMiddleware // Bearer JWT validation4. tenantMiddleware // user_roles → tenantId + role5. fhirMiddleware // EmaFhirClient per-tenant6. auditLog // AuditEvent + audit_logs↓ luego cada handler aplica:7. requireRole(...) // RBAC gate, 403 si no allowlistauthMiddleware 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:
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.
Componentes adicionales del frontend
Sección titulada «Componentes adicionales del frontend»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.