Ir al contenido

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.

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.

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.

tiene

registra

1:1 racha general

desbloquea

1:1 suscripción

bridge a FHIR

reflexión semanal

tiene N entries

1 entry por pilar

contiene

users

uuid

id

PK

text

email

pillars

uuid

id

PK

uuid

user_id

FK

text

name

text

track_type

boolean · duration · count · scale

numeric

goal_value

text

loinc_code

daily_logs

uuid

id

PK

uuid

user_id

FK

date

log_date

int

energia

1-5

int

mood

1-5

bool

impulso

text

wins

text

notas

streaks

uuid

user_id

PK

int

current_count

int

best_count

bool

freeze_used

date

freeze_month

achievements

uuid

id

PK

uuid

user_id

FK

text

type

streak_7 · phase_1 · …

timestamptz

unlocked_at

subscriptions

uuid

user_id

PK

text

tier

free · pro

text

billing_period

monthly · annual

text

stripe_customer_id

text

status

user_patient_map

uuid

user_id

PK

text

patient_ref

Patient/{id}

weeks

daily_log_entries

uuid

id

PK

uuid

daily_log_id

FK

uuid

pillar_id

FK

bool

done

numeric

value

uuid

fhir_obs_id

FK a schema fhir

routine_packs

routine_items

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.

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:

  • id UUID del recurso.
  • resource_type texto, en este producto típicamente Observation pero también Patient o Bundle cuando aplica.
  • resource JSONB con el recurso completo en formato FHIR R4.
  • subject_ref texto 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.

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:

TipoLOINC / SNOMEDNotas
Presión arterialmúltiplessistólica y diastólica
Altura8302-2cm
Peso29463-7kg
BMI39156-5calculado a partir de altura y peso
Temperatura8310-5°C
Glucosa2339-0mg/dL
Frecuencia cardíaca8867-4bpm
Saturación de oxígeno2708-6%
Energía87706-6 (LOINC)escala 1 a 5
Estado de ánimo285854004 (SNOMED)escala 1 a 5

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.

No

No

No

Llega 00:00 del día siguiente

¿El usuario completó

≥ 2 pilares ayer?

current_count += 1

actualizar best_count si aplica

¿Es Pro?

current_count = 0

¿Ya usó freeze

este mes?

freeze_used = true

freeze_month = mes actual

current_count se mantiene

achievement-engine evalúa

streak_7 / 21 / 30 / 60 / 90

Notificación: freeze aplicado

Notificación: racha rota

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.

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 public
user_id = auth.uid()
-- Schema fhir
subject.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.

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.