Modelo de datos
El modelo de datos de EMA Well refleja la dualidad entre lo
operacional (lo que el producto necesita para que funcione la UI) y
lo canónico (lo que el usuario puede exportar como información
clínica portable). La operación vive en el schema public con
tablas optimizadas para queries frecuentes; la información clínica
canónica vive en el schema fhir como Observation JSONB
estándar. Ambos schemas conviven en el mismo proyecto Supabase
aislado del resto del ecosistema —ninguna app del lado de Clinic,
Lab o Vault tiene acceso a este Postgres.
Por qué dos schemas
Sección titulada «Por qué dos schemas»Modelar las observaciones FHIR en JSONB sobre el mismo Postgres
permite mantener el cumplimiento estándar sin sumar la complejidad
de operar Aidbox para cada usuario individual. Las queries de UI
—que dominan la latencia percibida del producto— operan sobre el
schema public con campos tabulares y sin tocar JSONB; la
exportación FHIR (en Pro) se obtiene leyendo directamente del schema
fhir con los recursos ya armados.
Schema operacional
Sección titulada «Schema operacional»El schema public agrupa las tablas que el producto consulta y
muta en cada interacción del usuario. La tabla pillars define los
pilares activos; daily_logs y daily_log_entries registran cada
check-in con sus pilares marcados; streaks mantiene la racha
calculada; subscriptions controla el tier; achievements registra
los logros desbloqueados.
La tabla user_patient_map merece atención: actúa como puente entre
el user_id operacional (que usa el resto del schema) y la
referencia FHIR Patient/{id} que vive en el schema fhir. Esa
función indirecta permite cambiar el formato de identidad sin
romper el modelo, y es lo que habilita las políticas RLS sobre el
schema fhir.
A las tablas centrales se suman varias auxiliares con propósito más
acotado: routine_packs y routine_items contienen las rutinas
curadas que el sistema sugiere durante el onboarding; weeks
guarda la reflexión semanal por usuario, año y semana;
daily_suggestions almacena los insights generados por Claude
después de cada check-in (solo en Pro); escape_pods lista las
acciones rápidas que el usuario puede ejecutar cuando se siente
abrumado.
Schema FHIR
Sección titulada «Schema FHIR»El schema fhir contiene una sola tabla, fhir_resources,
construida con la simplicidad que permite Postgres moderno. Cada
fila es un recurso FHIR completo serializado como JSONB:
idUUID del recurso.resource_typetexto, en este producto típicamenteObservationpero tambiénPatientoBundlecuando aplica.resourceJSONB con el recurso completo en formato FHIR R4.subject_reftexto con la referencia FHIR del paciente (Patient/{id}) extraída del JSONB para indexación.
Los índices viven sobre (subject_ref, resource_type), lo que
permite consultar todos los recursos de un paciente de un tipo dado
en tiempo constante. Es el patrón clásico de single-table FHIR sobre
Postgres.
Vitales soportados
Sección titulada «Vitales soportados»El producto reconoce diez tipos de observación con códigos LOINC
y SNOMED globales. Los display que el usuario ve están
localizados al idioma de su perfil; los códigos canónicos almacenados
son los mismos para todos los países:
| Tipo | LOINC / SNOMED | Notas |
|---|---|---|
| Presión arterial | múltiples | sistólica y diastólica |
| Altura | 8302-2 | cm |
| Peso | 29463-7 | kg |
| BMI | 39156-5 | calculado a partir de altura y peso |
| Temperatura | 8310-5 | °C |
| Glucosa | 2339-0 | mg/dL |
| Frecuencia cardíaca | 8867-4 | bpm |
| Saturación de oxígeno | 2708-6 | % |
| Energía | 87706-6 (LOINC) | escala 1 a 5 |
| Estado de ánimo | 285854004 (SNOMED) | escala 1 a 5 |
Cómo se calcula la racha
Sección titulada «Cómo se calcula la racha»La racha es el indicador más visible del producto y su cálculo combina dos reglas: el cumplimiento mínimo del día (al menos dos pilares marcados) y, en cuentas Pro, el mecanismo de freeze que permite proteger un día perdido al mes.
El cálculo corre cada día a la medianoche del usuario (no UTC) y, si se incrementa la racha, se evalúan los achievements de tipo streak en orden creciente —siete, veintiuno, treinta, sesenta y noventa días— para detectar nuevos hitos desbloqueados. La diferencia entre Free y Pro está en la rama del freeze: en Free, no completar dos pilares rompe la racha sin excepción; en Pro, el sistema verifica si el freeze del mes ya se usó y, si no, lo aplica automáticamente.
RLS y aislamiento por usuario
Sección titulada «RLS y aislamiento por usuario»La RLS sobre los dos schemas es el mecanismo de aislamiento que garantiza que un usuario solo accede a sus propios datos. Las políticas son simples y explícitas:
-- Schema publicuser_id = auth.uid()
-- Schema fhirsubject.reference = current_patient_ref()La función current_patient_ref() es un puente que mapea
auth.uid() a user_patient_map.patient_ref. Eso permite escribir
políticas naturales sobre Patient/{id} (la referencia FHIR
estándar) sin filtrar manualmente por user_id en cada query, y
mantiene la separación conceptual entre el ID operacional y la
identidad clínica.
Patrones de almacenamiento
Sección titulada «Patrones de almacenamiento»Cuatro convenciones cruzan todo el modelo. Los IDs son UUID en
todas las tablas, lo que evita predicibilidad y elimina las
secuencias autoincrementales. Los recursos FHIR se almacenan como
JSONB completos sin normalizar campos individuales, porque el
estándar evoluciona y normalizar a tablas separadas habría obligado
a migrar el schema cada vez que FHIR R5 trae algo nuevo. Las FK
usan cascada cuando corresponde, especialmente
daily_log_entries.daily_log_id → daily_logs.id ON DELETE CASCADE,
para que borrar un check-in entero por error o por solicitud del
usuario limpie sus entries asociadas. Y el producto no usa ORM:
las queries van directas via Supabase JS client desde el worker
Hono, lo que mantiene la latencia baja y el control total sobre el
SQL generado.