Esquema de tablas
organizations
Raíz del árbol multi-tenant. Cada clínica o consulta.
org_idPK · IDENTITY
BIGINTnameNOT NULL · CHECK len
TEXTslugUNIQUE · regex
TEXTplanCHECK IN list
TEXTsettingsCHECK object
JSONBcreated_atDEFAULT now()
TIMESTAMPTZusers
Nutricionistas y pacientes con rol y hash de contraseña.
user_idPK · IDENTITY
BIGINTorg_idFK → organizations
BIGINTemailDOMAIN email · UQ(org,email)
emailpassword_hashNOT NULL · pgcrypto
TEXTroleNOT NULL
user_role ENUMis_activeNOT NULL DEFAULT TRUE
BOOLEANlast_loginNULL OK
TIMESTAMPTZÍndices
EXPRUNIQUE (org_id, LOWER(email)) — búsquedas case-insensitive
B-tree(org_id) — FK manual obligatorio
patients
Perfil extendido del paciente. Arrays para goals y alergias.
patient_idPK · IDENTITY
BIGINTuser_idFK UNIQUE → users
BIGINTnutritionist_idFK → users
BIGINTbirth_dateNULL OK
DATEgoals
TEXT[]allergies
TEXT[]metadataCHECK object
JSONBÍndices
GIN(goals) y (allergies) — @>, && en arrays
B-tree(nutritionist_id) — FK manual
food_items
Catálogo de alimentos con macros y búsqueda full-text/fuzzy.
food_idPK · IDENTITY
BIGINTorg_idFK nullable (global si NULL)
BIGINTcalories_kcalCHECK ≥ 0
NUMERIC(8,2)protein_g / carbs_g / fat_g
NUMERIC(8,2)search_vectorGENERATED STORED
TSVECTORtags
TEXT[]Índices
GIN(search_vector) — full-text @@ tsquery
GIN(name gin_trgm_ops) — LIKE '%query%' fuzzy
PARTIAL(name) WHERE org_id IS NULL — ítems globales
meal_plans
Planes de alimentación con EXCLUDE para evitar solapamientos.
plan_idPK · IDENTITY
BIGINTpatient_idFK → patients
BIGINTnutritionist_idFK → users
BIGINTstatusENUM
plan_statusactive_periodEXCLUDE (patient_id =, period &&)
DATERANGEÍndices
GiST(active_period) — overlap queries + EXCLUDE
B-tree(patient_id), (nutritionist_id), (status)
meal_entries
Registro diario de comidas. Columna rating extraída de JSONB.
entry_idPK · IDENTITY
BIGINTpatient_idFK → patients
BIGINTfood_idFK → food_items
BIGINTmeal_type
meal_type ENUMquantity_g / calories_kcalCHECK > 0
NUMERIC(8,2)metafoto_url, notas, fuente…
JSONBratingGENERATED from meta->>'rating'
SMALLINTÍndices
B-tree(patient_id, eaten_at DESC) — timeline
GIN(meta) — búsquedas en metadatos JSONB
biometric_logs
Hypertabla TimescaleDB. >500M filas. Upserts de wearables.
patient_idPK compuesta
BIGINTmetric_typeCHECK IN 6 valores
TEXTrecorded_atPK · partición key
TIMESTAMPTZvalue
NUMERIC(10,4)sourcefitbit|apple_health|manual…
TEXTÍndices + Políticas TimescaleDB
B-tree(patient_id, recorded_at DESC)
PARTIAL(patient_id, metric_type, recorded_at DESC) WHERE > now()-90d
COMPRESSchunks > 6 meses · retención 5 años
ai_recommendations
Recomendaciones IA con embeddings de 1536 dimensiones.
rec_idPK · IDENTITY
BIGINTpatient_idFK → patients
BIGINTembeddingtext-embedding-3-small
vector(1536)categoryCHECK IN 5 categorías
TEXTconfidenceCHECK 0..1
NUMERIC(4,3)is_readNOT NULL DEFAULT FALSE
BOOLEANÍndices
IVFFLAT(embedding vector_cosine_ops) lists=100
PARTIAL(patient_id) WHERE is_read = FALSE — notificaciones
Reglas de diseño aplicadas
🔑 Tipos de datos
- BIGINT GENERATED ALWAYS AS IDENTITY en todas las PKs (no SERIAL)
- NUMERIC(p,s) para macros/calorías (nunca FLOAT)
- TIMESTAMPTZ para fechas (no TIMESTAMP sin zona)
- TEXT para strings (no VARCHAR/CHAR)
- DOMAIN email reutilizable con regex CHECK
🗂 Indexación
- FK manuales indexadas (PostgreSQL no las indexa automáticamente)
- B-tree compuesto (patient_id, eaten_at DESC) para timeline
- Índice PARTIAL para datos calientes (<90 días)
- UNIQUE EXPR LOWER(email) para case-insensitive
- GIN en arrays TEXT[] y JSONB para operadores @>, &&
⚡ Workloads especiales
- Hypertabla TimescaleDB: chunk mensual, compresión 6m, retención 5a
- Upsert: ON CONFLICT en PK compuesta (patient, metric, time)
- EXCLUDE GIST en daterange: previene planes solapados
- UNLOGGED viable para staging de sync de wearables
- Columna GENERATED STORED: search_vector y rating
🔒 Seguridad y RLS
- RLS activado en users, patients, biometric_logs
- Función current_org_id() desde JWT session
- Nutricionista ve solo sus pacientes; paciente solo a sí mismo
- pgcrypto para hashing de contraseñas (crypt())
- pgaudit habilitado para auditoría completa
🧬 Características avanzadas
- pgvector: embeddings 1536-dim con índice IVFFLAT coseno
- pg_trgm: GIN sobre nombre de alimento para LIKE '%query%'
- FTS: TSVECTOR generado con idioma 'spanish' explícito
- JSONB: GIN default para @> y ? ; B-tree extraído para igualdad
- DATERANGE + EXCLUDE para validación anti-solapamiento
🛤 Migración segura
- Columnas nullable primero; NOT NULL después de rellenar
- DDL transaccional: BEGIN/ALTER/ROLLBACK para pruebas seguras
- CREATE INDEX CONCURRENTLY para producción sin bloquear
- Defaults NO volátiles para evitar rewrites de tabla
- DROP CONSTRAINT antes de DROP COLUMN
Fragmento SQL clave — biometric_logs (upsert wearable)
upsert.sql
ON CONFLICT · DO UPDATE
-- Sync wearable Fitbit → upsert atómico y eficiente -- Requiere PK compuesta: (patient_id, metric_type, recorded_at) INSERT INTO biometric_logs (patient_id, metric_type, recorded_at, value, source) VALUES (42, 'weight_kg', '2026-06-16 08:00:00+02', 73.4, 'fitbit'), (42, 'heart_rate_bpm', '2026-06-16 08:00:00+02', 64, 'fitbit'), (42, 'body_fat_pct', '2026-06-16 08:00:00+02', 18.2, 'fitbit') ON CONFLICT (patient_id, metric_type, recorded_at) DO UPDATE SET value = EXCLUDED.value, source = EXCLUDED.source -- Solo escribe si el valor cambió (reduce WAL bloat) WHERE biometric_logs.value IS DISTINCT FROM EXCLUDED.value;
fts_search.sql
Full-text + Fuzzy
-- Búsqueda de alimentos: full-text primero, fallback fuzzy SELECT food_id, name, brand, calories_kcal, ts_rank(search_vector, query) AS rank FROM food_items, to_tsquery('spanish', 'pollo & asado') query WHERE search_vector @@ query OR name % 'pollo asado' -- pg_trgm similarity fallback ORDER BY rank DESC, similarity(name, 'pollo asado') DESC LIMIT 20; -- Búsqueda vectorial de recomendaciones similares SELECT rec_id, content, category, confidence FROM ai_recommendations WHERE patient_id = 42 ORDER BY embedding <=> '[0.12, -0.34, ...]'::vector -- coseno LIMIT 5;