Saltar al contenido principal

Esquema de Base de Datos

Este documento define la arquitectura de datos del sistema. Se utiliza una base de datos relacional (PostgreSQL) con extensiones geográficas (PostGIS) para optimizar las búsquedas por cercanía.


Relación: Traduce el Modelo de Dominio a SQL/NoSQL.

Alcance

Este documento contendrá la estructura de tablas y entidades de la base de datos del proyecto (por ejemplo users, profiles, y las entidades del modelo de dominio). Se irá completando conforme se avance en la tarea DOM-01 del Roadmap Técnico.

Lineamientos de modelado

  • PK expuestas a API: usar uuid (DEFAULT gen_random_uuid()); evitar IDs autoincrementales en interfaces públicas.
  • Public vs internal ID: si hay PK interna numérica, generar public_id UUID y exponer solo ese en API.
  • FKs: mantener integridad referencial; usar ON DELETE CASCADE donde aplique (ej. affiliaciones, tokens).
  • Índices: GIST para geolocalización, GIN para FTS.
  • Borrado lógico: deleted_at/is_active para histórico.

1. Modelo Entidad-Relación (ERD)

Este diagrama representa la estructura de tablas y sus vínculos. Se prioriza la integridad referencial para la Validación Triple.


2. Diccionario de Tablas Principales

2.1 Tabla: users (Identidad Core)

CampoTipoRestriccionesDescripción
idUUIDPK, DEFAULT gen_random_uuid()ID global de usuario.
emailVARCHAR(255)UNIQUE, INDEX, NOT NULLCorreo electrónico principal.
password_hashTEXTNOT NULLHash generado con Argon2id.
rolesTEXT[] / ENUM[]DEFAULT ['PATIENT']Roles acumulativos: PATIENT, DOCTOR, ENTITY_ADMIN, CATALOG_ADMIN, SUPERADMIN.
statusENUMPENDING, ACTIVE, SUSPENDEDEstado vital de la cuenta.
mfa_secretTEXTNULLABLESecreto para TOTP (Google Authenticator).
is_verifiedBOOLEANDEFAULT FALSEEstado de verificación de identidad.

2.2 Tabla: refresh_tokens (Seguridad de Sesión)

Implementa Refresh Token Rotation y Blacklisting.

CampoTipoRestriccionesDescripción
idUUIDPKID único del token.
user_idUUIDFK -> users.id, INDEXUsuario dueño de la sesión.
token_hashTEXTUNIQUE, NOT NULLHash del token para comparación segura.
expires_atTIMESTAMPTZNOT NULLFecha de caducidad (TTL).
is_revokedBOOLEANDEFAULT FALSEUsado para invalidar sesiones robadas.

2.3 Tabla: verification_tokens (Identidad)

CampoTipoRestriccionesDescripción
idUUIDPKToken de verificación/recuperación.
user_idUUIDFK -> users.id, INDEXUsuario objetivo.
purposeENUMVERIFY_EMAIL, RESET_PASSWORD, MFA_CHALLENGEMotivo.
token_hashTEXTNOT NULLHash del código/OTP.
expires_atTIMESTAMPTZNOT NULLTTL corto (15-30 min).
used_atTIMESTAMPTZNULLABLEMarcar consumo único.

2.4 Tabla: user_profiles (Datos comunes)

CampoTipoRestriccionesDescripción
user_idUUIDPK, FK -> users.idIdentidad base.
first_nameVARCHAR(120)NOT NULLNombre(s).
last_nameVARCHAR(120)NOT NULLApellido(s).
full_nameVARCHAR(255)GENERATED ALWAYS AS (first_name
phoneVARCHAR(20)NOT NULLTeléfono (PII, cifrado en app antes de DB).
birth_dateDATENULLABLEFecha de nacimiento.
country_codeCHAR(2)NOT NULLISO país (selector LATAM).
languageVARCHAR(10)DEFAULT 'es'Preferencia de idioma.
primary_insurance_idUUIDNULLABLEConvenio principal (opcional).
created_atTIMESTAMPTZDEFAULT now()Auditoría.
updated_atTIMESTAMPTZDEFAULT now()Auditoría.

Auditoría y trazabilidad: toda actualización de user_profiles debe emitir evento USER_PROFILE_UPDATED con changed_by, timestamp y diff en metadatos. Ideal capturar IP/UA para cumplir trazas de seguridad.

2.4 Tabla: doctor_profiles

CampoTipoRestriccionesDescripción
idUUIDPKIdentificador del perfil.
user_idUUIDFK -> users.id, UNIQUEVínculo con cuenta Auth.
professional_prefixENUMDR, DRA, ENF, LIC, ODONT, PSIC, NUT, FISIO, QF, TECPrefijo mostrado en perfil público.
license_numberVARCHAR(20)UNIQUENúmero de colegiado.
specialty_idUUIDFK -> medical_taxonomy.idEspecialidad primaria.
metadataJSONBNOT NULLFormación, idiomas, subs, documentos.
statusENUMPENDING, APPROVED, REJECTED, DISABLEDRevisión y habilitación.
created_byUUIDFK -> users.idAuditoría (quién solicitó).
approved_byUUIDFK -> users.idAuditoría (quién aprobó).

Auditoría: cambios y aprobaciones deben emitir eventos DOCTOR_PROFILE_UPDATED / DOCTOR_PROFILE_APPROVED con changed_by, timestamp e IP/UA.

2.5 Tabla: medical_taxonomy (Areas y Especialidades)

CampoTipoRestriccionesDescripción
idUUIDPKIdentificador del nodo.
codeVARCHAR(64)UNIQUE NOT NULLCódigo estable (ej. area_infantil, esp_pediatria).
nameVARCHAR(255)NOT NULLNombre a mostrar.
slugVARCHAR(255)UNIQUEPara URLs/búsqueda.
parent_idUUIDFK -> medical_taxonomy.id, NULLABLEJerarquia area -> especialidad.
enabledBOOLEANDEFAULT TRUEControl de exposición.
created_atTIMESTAMPTZDEFAULT now()Auditoría.
updated_atTIMESTAMPTZDEFAULT now()Auditoría.

Uso: las areas tienen parent_id = null; las especialidades tienen parent_id apuntando al area principal.

2.6 Tabla: area_taxonomy (Relacion muchas-a-muchas area ↔ especialidad)

CampoTipoRestriccionesDescripción
area_idUUIDPK parcial, FK -> medical_taxonomy(id)Debe ser un nodo área (parent_id null).
taxonomy_idUUIDPK parcial, FK -> medical_taxonomy(id)Especialidad reutilizable.
created_atTIMESTAMPTZDEFAULT now()Auditoría.

Permite reutilizar especialidades en multiples areas cuando aplique (UNIQUE(area_id, taxonomy_id)).

2.6 Tabla: health_entities (Sedes Polimórficas)

CampoTipoRestriccionesDescripción
idUUIDPKID de la sede física.
typeENUMCLINIC, LAB, PHARMACYMódulo de dominio.
owner_user_idUUIDFK -> users.idDueño/gestor principal.
nameVARCHAR(255)NOT NULLNombre comercial indexable.
legal_nameVARCHAR(255)NOT NULLRazón social.
emailVARCHAR(255)NOT NULLContacto principal.
addressTEXTNOT NULLDirección descriptiva.
locationGEOGRAPHY(POINT)INDEX GISTCoordenadas PostGIS (4326).
geography_idUUIDFK -> geography.idVinculo a localidad (municipio/ciudad) o region segun el pais.
configJSONBNOT NULLDatos extendidos: schedules, services, amenities, terms_version_accepted.
statusENUMPENDING, APPROVED, REJECTED, DISABLEDFlujo de aprobación.

Auditoría: creación/actualización debe emitir ENTITY_UPDATED con changed_by, timestamp e IP/UA. Rechazos/aprobaciones deben registrar motivo.

2.7 Tabla: affiliations (Intersección Operativa)

CampoTipoRestriccionesDescripción
doctor_idUUIDFK -> doctor_profiles.id ON DELETE CASCADEEl médico que atiende.
entity_idUUIDFK -> HEALTH_ENTITIES ON DELETE CASCADELa sede donde atiende.
scheduleJSONBNOT NULLDías y horas (Ej: {"Mon": ["08:00-12:00"]}).
room_numberVARCHAR(50)NULLABLEOficina o consultorio específico.
PK(doctor_id, entity_id)Evita duplicados.

2.8 Tabla: insurances

CampoTipoRestriccionesDescripción
idUUIDPKIdentificador del seguro.
nameVARCHAR(255)UNIQUE, NOT NULLNombre comercial.
logo_urlTEXTNULLABLELogo para UI.
plansJSONBNULLABLELista de planes soportados.

2.9 Tabla: insurance_agreements

CampoTipoRestriccionesDescripción
idUUIDPKID del convenio.
insurance_idUUIDFK -> insurances.idAseguradora.
entity_idUUIDFK -> health_entities.idSede con convenio.
coverageJSONBNOT NULLPlanes, copagos, restricciones.
statusENUMACTIVE, SUSPENDEDEstado operativo.
UK(insurance_id,entity_id)Evita convenios duplicados.

2.10 Tabla: doctor_accreditations

CampoTipoRestriccionesDescripción
idUUIDPKID del registro.
doctor_idUUIDFK -> doctor_profiles.id ON DELETE CASCADEProfesional.
insurance_idUUIDFK -> insurances.idSeguro que acredita.
credential_urlTEXTNULLABLEEvidencia digital.
statusENUMACTIVE, PENDING, REJECTEDEstado de acreditación.
UK(doctor_id,insurance_id)Evita acreditaciones duplicadas.

2.11 Tabla: reviews

CampoTipoRestriccionesDescripción
idUUIDPKID de la reseña.
patient_idUUIDFK -> users.id (rol PATIENT)Autor.
doctor_idUUIDFK -> doctor_profiles.idOpcional si reseña al doctor.
entity_idUUIDFK -> health_entities.idOpcional si reseña la sede.
ratingSMALLINTCHECK 1-5Puntuación.
commentTEXTNULLABLETexto libre.
evidence_file_idUUIDFK -> file_assets.idComprobante requerido si negativa.
statusENUMPENDING, PUBLISHED, REJECTEDModeración.

2.12 Tabla: appointments (Citas)

CampoTipoRestriccionesDescripción
idUUIDPKID de la cita.
patient_idUUIDFK -> users.id (rol PATIENT)Paciente que agenda.
doctor_idUUIDFK -> doctor_profiles.id ON DELETE CASCADEProfesional.
entity_idUUIDFK -> health_entities.id ON DELETE CASCADESede donde atiende.
insurance_idUUIDFK -> insurances.idSeguro aplicado (NULL si particular).
slot_startTIMESTAMPTZNOT NULLInicio de la cita.
slot_endTIMESTAMPTZNOT NULLFin de la cita. (CHECK slot_end > slot_start).
statusENUMPENDING, CONFIRMED, CANCELLED, NOSHOWEstado operativo.
reasonVARCHAR(255)NULLABLEMotivo breve (sin PHI).
UK(doctor_id,slot_start,slot_end)Evita doble booking exacto.
IDX(patient_id,slot_start)Consultas de historial.
room_numberVARCHAR(50)NULLABLESala/consultorio.
created_atTIMESTAMPTZDEFAULT now()
updated_atTIMESTAMPTZDEFAULT now()
deleted_atTIMESTAMPTZNULLABLESoft delete.

2.13 Tabla: notification_preferences

CampoTipoRestriccionesDescripción
user_idUUIDPK, FK -> users.id ON DELETE CASCADEDueño de la preferencia.
email_enabledBOOLEANDEFAULT TRUEOpt-in email transaccional.
sms_enabledBOOLEANDEFAULT TRUEOpt-in SMS transaccional.
push_enabledBOOLEANDEFAULT TRUEOpt-in push.
marketing_opt_inBOOLEANDEFAULT FALSEOpt-in marketing.
updated_atTIMESTAMPTZDEFAULT now()Control de cambios.

2.14 Tabla: notification_logs

CampoTipoRestriccionesDescripción
idUUIDPKID del log.
user_idUUIDFK -> users.id ON DELETE SET NULLReceptor.
channelENUMEMAIL, SMS, PUSH, INAPPCanal usado.
templateVARCHAR(100)NOT NULLPlantilla disparada.
statusENUMSENT, FAILED, RETRIEDEstado de envío.
provider_msg_idVARCHAR(255)NULLABLEID del proveedor (SES/Twilio).
errorTEXTNULLABLEMensaje de error si aplica.
created_atTIMESTAMPTZDEFAULT now()

2.15 Tabla: file_assets

CampoTipoRestriccionesDescripción
idUUIDPKID del archivo.
owner_idUUIDFK -> users.idPropietario.
bucket_pathTEXTNOT NULLRuta en S3/CloudFront.
mime_typeVARCHAR(80)NOT NULLTipo de archivo.
size_bytesBIGINTCHECK > 0Tamaño para control de costos.

2.16 Tabla: audit_logs

CampoTipoDescripción
idUUIDPK
user_idUUIDFK -> users.id
actionVARCHAR(50)LOGIN_MFA, UPDATE_LICENSE, VIEW_PATIENT_DATA
old_valuesJSONBEstado previo
new_valuesJSONBEstado posterior
ip_addressINETIP origen
trace_idUUIDCorrelación con OpenTelemetry
created_atTIMESTAMPTZDEFAULT now()

2.17 Tabla: coupons (Promociones)

CampoTipoRestriccionesDescripción
idUUIDPKID del cupón.
owner_idUUIDFK -> users.id ON DELETE CASCADEDueño (DOCTOR/ENTITY_ADMIN).
entity_idUUIDFK -> health_entities.idAlcance por sede (opcional).
codeVARCHAR(32)UNIQUE, NOT NULLCódigo visible al paciente.
titleVARCHAR(120)NOT NULLNombre de campaña.
descriptionTEXTNULLABLECopy promocional.
discount_typeENUMPERCENT, FIXEDTipo de descuento.
discount_valueNUMERIC(10,2)CHECK > 0Valor del descuento.
starts_atTIMESTAMPTZNOT NULLInicio vigencia.
ends_atTIMESTAMPTZNOT NULLFin vigencia.
max_redemptionsINTEGERNULLABLECupo total.
max_per_userINTEGERDEFAULT 1Cupo por usuario.
statusENUMDRAFT, ACTIVE, PAUSED, REJECTED, EXPIREDEstado operativo.
created_atTIMESTAMPTZDEFAULT now()
updated_atTIMESTAMPTZDEFAULT now()
deleted_atTIMESTAMPTZNULLABLESoft delete.

2.18 Tabla: professional_posts (Blog profesional)

CampoTipoRestriccionesDescripción
idUUIDPKID del artículo.
author_idUUIDFK -> users.id ON DELETE CASCADEAutor (DOCTOR/ENTITY_ADMIN).
entity_idUUIDFK -> health_entities.idSede asociada (opcional).
titleVARCHAR(180)NOT NULLTítulo del contenido.
slugVARCHAR(200)UNIQUE, NOT NULLURL amigable.
excerptVARCHAR(320)NULLABLEResumen corto.
bodyTEXTNOT NULLContenido principal.
cover_file_idUUIDFK -> file_assets.idPortada opcional.
statusENUMDRAFT, PUBLISHEDEstado editorial.
published_atTIMESTAMPTZNULLABLEFecha de publicación.
created_atTIMESTAMPTZDEFAULT now()
updated_atTIMESTAMPTZDEFAULT now()
deleted_atTIMESTAMPTZNULLABLESoft delete.

3. Catálogos Jerárquicos (Master Data)

Para evitar texto libre, estas tablas gestionan la normalización:

  1. GEOGRAPHY: Tabla recursiva (id, name, parent_id, level).
    • Niveles canonicos: 0:Pais, 1:Region, 2:Localidad.
    • Labels por pais (Departamento/Estado/Provincia, Municipio/Ciudad/Partido) se resuelven por metadata, no por columnas nuevas.
  2. MEDICAL_TAXONOMY: Tabla recursiva (id, name, parent_id, level).
    • Niveles: 0:Area, 1:Especialidad.

4. Estrategia de Índices y Rendimiento

  1. Geo-búsqueda: CREATE INDEX idx_location ON health_entities USING GIST (location);
  2. Validación Triple: CREATE INDEX idx_triple_val ON affiliations (doctor_id, entity_id);
  3. Búsqueda Semántica: CREATE INDEX idx_doc_metadata ON doctor_profiles USING GIN (metadata);
  4. Sesiones: CREATE INDEX idx_token_expiry ON refresh_tokens (expires_at) WHERE is_revoked IS FALSE;
  5. Full-Text Search: Uso de tsvector en PostgreSQL para búsquedas por síntomas o nombres en los metadatos JSONB.
  6. Particionado opcional: Evaluar particionado por fecha en audit_logs y reviews si el volumen supera 50M registros/año.
  7. Citas: CREATE INDEX idx_appointments_patient ON appointments (patient_id, slot_start DESC); y CREATE INDEX idx_appointments_doctor ON appointments (doctor_id, slot_start DESC);
  8. Convenios: CREATE INDEX idx_agreements_insurance ON insurance_agreements (insurance_id, entity_id);
  9. FTS Doctores/Entidades: CREATE INDEX idx_doctor_search ON doctor_profiles USING GIN (to_tsvector('spanish', metadata::text)); y CREATE INDEX idx_entity_search ON health_entities USING GIN (to_tsvector('spanish', name || ' ' || config::text));
  10. Trazas/Logs: incluir siempre trace_id, user_id/doctor_id/entity_id en logs de mutaciones críticas para correlación con OTel.
  11. Cupones: CREATE INDEX idx_coupons_owner_status ON coupons (owner_id, status, ends_at);
  12. Blog profesional: CREATE INDEX idx_posts_author_status ON professional_posts (author_id, status, published_at DESC);

5. Auditoría y Trazabilidad

Toda tabla crítica debe incluir campos de control:

  • created_at: Timestamp de creación.
  • updated_at: Timestamp de última modificación.
  • deleted_at: Timestamp para Borrado Lógico (Soft Delete).
  • version: Entero para control de concurrencia optimista.

Toda mutación de datos sensibles o PHI (Protected Health Information) se registra en audit_logs:

CampoTipoDescripción
actionVARCHAR(50)LOGIN_MFA, UPDATE_LICENSE, VIEW_PATIENT_DATA.
old_valuesJSONBEstado del registro antes del cambio.
new_valuesJSONBEstado del registro después del cambio.
ip_addressINETIP origen de la petición.
trace_idUUIDID para correlación con OpenTelemetry (Go logs).

6. Requerimientos No Funcionales (NFR) de Datos

6.1 Retención y Borrado

  • Soft Delete: Se implementa la columna deleted_at en todas las tablas transaccionales.
  • Retención PII: Datos de usuarios borrados se anonimizan tras 24 meses; se conservan agregados estadísticos sin PII.

6.2 Estrategia de Backup

  • RPO (Recovery Point Objective): ≤ 5 minutos (RDS Point-in-time recovery + snapshots horarios si aplica).
  • RTO (Recovery Time Objective): ≤ 60 minutos para restauración completa de base de datos en producción.
  • Snapshot: Snapshot automático diario con retención de 35 días.

6.3 Protección de PII/PHI

  • Cifrado en tránsito: TLS 1.3 obligatorio entre servicios y DB.
  • Cifrado en reposo: RDS TDE habilitado; columnas sensibles (phone, mfa_secret) cifradas en capa aplicación antes de persistir.
  • Mascaramiento: Endpoints públicos nunca retornan PII completa; aplicar masking en logs y trazas.
DatasetTrigger de anonimizaciónTécnicaExcepciones
user_profilesCuenta eliminada + 24 mesesHash irreversible de email/teléfono + null de PII directaLitigios o fraude activo (legal_hold=true)
appointmentsVentana legal vencidaRemover datos de contacto y conservar métricas operativasInvestigación médica/seguridad aprobada
reviewsSolicitud GDPR/LOPDReemplazar autor por seudónimo no reversibleModeración pendiente
audit_logsRetención cumplida (mín. 24 meses)Purga por partición + checksum de evidenciaIncidente abierto o auditoría regulatoria

Reglas:

  • La anonimización debe ser idempotente y auditable (ANONYMIZATION_EXECUTED en audit_logs).
  • legal_hold se define por soporte/compliance y bloquea purga/anonimización automática.
  • No se permite restaurar PII luego de anonimizar; solo conservar trazabilidad técnica mínima.

7. Matrix de Trazabilidad (Brecha Corta)

Entidad DominioTabla DBEndpoint Go (CRUD)Owner Técnico
Doctordoctor_profilesPOST /api/v1/doctorsBackend Team
Sedehealth_entitiesPOST /api/v1/entitiesBackend Team
SeguroinsurancesGET /api/v1/insurancesBackend Team
Sesiónrefresh_tokensPOST /api/v1/auth/refreshSecurity Team
Acreditacióndoctor_accreditationsPOST /api/v1/doctor-accreditationsBackend Team
Convenioinsurance_agreementsPOST /api/v1/insurance-agreementsBackend Team
ReseñareviewsPOST /api/v1/reviewsProduct & QA

8. ENUMs Oficiales

Para mantener coherencia entre BD, API y frontend, estos valores son únicos y deben versionarse.

NombreValoresUso
user_rolePATIENT, DOCTOR, ENTITY_ADMIN, CATALOG_ADMIN, SUPERADMINusers.roles (claims y autorización).
user_statusPENDING, ACTIVE, SUSPENDEDusers.status.
verification_purposeVERIFY_EMAIL, RESET_PASSWORD, MFA_CHALLENGEverification_tokens.purpose.
entity_typeCLINIC, LAB, PHARMACYhealth_entities.type.
agreement_statusACTIVE, SUSPENDEDinsurance_agreements.status.
accreditation_statusACTIVE, PENDING, REJECTEDdoctor_accreditations.status.
professional_prefixDR, DRA, ENF, LIC, ODONT, PSIC, NUT, FISIO, QF, TECdoctor_profiles.professional_prefix (display).
review_statusPENDING, PUBLISHED, REJECTEDreviews.status.
review_rating1,2,3,4,5Constraint CHECK en reviews.rating.
coupon_statusDRAFT, ACTIVE, PAUSED, REJECTED, EXPIREDcoupons.status.
coupon_discount_typePERCENT, FIXEDcoupons.discount_type.
post_statusDRAFT, PUBLISHEDprofessional_posts.status.

9. DDL sugerido para ENUMs (PostgreSQL)

CREATE TYPE user_role AS ENUM ('PATIENT', 'DOCTOR', 'ENTITY_ADMIN', 'CATALOG_ADMIN', 'SUPERADMIN');
CREATE TYPE user_status AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED');
CREATE TYPE verification_purpose AS ENUM ('VERIFY_EMAIL', 'RESET_PASSWORD', 'MFA_CHALLENGE');
CREATE TYPE entity_type AS ENUM ('CLINIC', 'LAB', 'PHARMACY');
CREATE TYPE agreement_status AS ENUM ('ACTIVE', 'SUSPENDED');
CREATE TYPE accreditation_status AS ENUM ('ACTIVE', 'PENDING', 'REJECTED');
CREATE TYPE professional_prefix AS ENUM ('DR', 'DRA', 'ENF', 'LIC', 'ODONT', 'PSIC', 'NUT', 'FISIO', 'QF', 'TEC');
CREATE TYPE review_status AS ENUM ('PENDING', 'PUBLISHED', 'REJECTED');
CREATE TYPE coupon_status AS ENUM ('DRAFT', 'ACTIVE', 'PAUSED', 'REJECTED', 'EXPIRED');
CREATE TYPE coupon_discount_type AS ENUM ('PERCENT', 'FIXED');
CREATE TYPE post_status AS ENUM ('DRAFT', 'PUBLISHED');
-- Para rating usamos SMALLINT con CHECK (1-5) en lugar de ENUM para facilitar agregados

10. DDL de Tablas (Ejemplo)

Referencia rápida para implementación en migrations. Ajustar tamaños y defaults según decisión final de infraestructura.

CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
roles TEXT[] NOT NULL DEFAULT ARRAY['PATIENT'],
status user_status NOT NULL DEFAULT 'PENDING',
mfa_secret TEXT,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);

CREATE TABLE user_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id),
first_name VARCHAR(120) NOT NULL,
last_name VARCHAR(120) NOT NULL,
full_name VARCHAR(255) GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED,
phone VARCHAR(20) NOT NULL,
birth_date DATE,
country_code CHAR(2) NOT NULL,
language VARCHAR(10) NOT NULL DEFAULT 'es',
primary_insurance_id UUID REFERENCES insurances(id),
dependents JSONB,
frequent_locations JSONB,
medical_history JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
purpose verification_purpose NOT NULL,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);

CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
is_revoked BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE doctor_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
professional_prefix professional_prefix NOT NULL DEFAULT 'DR',
license_number VARCHAR(20) UNIQUE,
specialty_id UUID REFERENCES medical_taxonomy(id),
metadata JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE geography (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES geography(id),
level SMALLINT NOT NULL CHECK (level BETWEEN 0 AND 2)
);
CREATE INDEX idx_geo_parent ON geography(parent_id);

CREATE TABLE medical_taxonomy (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE,
parent_id UUID REFERENCES medical_taxonomy(id),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_medtax_parent ON medical_taxonomy(parent_id);

CREATE TABLE area_taxonomy (
area_id UUID NOT NULL REFERENCES medical_taxonomy(id),
taxonomy_id UUID NOT NULL REFERENCES medical_taxonomy(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (area_id, taxonomy_id)
);

CREATE TABLE health_entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type entity_type NOT NULL,
name VARCHAR(255) NOT NULL,
location GEOGRAPHY(POINT, 4326),
geography_id UUID REFERENCES geography(id),
config JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE affiliations (
doctor_id UUID REFERENCES doctor_profiles(id),
entity_id UUID REFERENCES health_entities(id),
schedule JSONB NOT NULL,
room_number VARCHAR(50),
PRIMARY KEY (doctor_id, entity_id)
);

CREATE TABLE insurances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
logo_url TEXT,
plans JSONB
);

CREATE TABLE insurance_agreements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
insurance_id UUID NOT NULL REFERENCES insurances(id),
entity_id UUID NOT NULL REFERENCES health_entities(id),
coverage JSONB NOT NULL,
status agreement_status NOT NULL DEFAULT 'ACTIVE',
UNIQUE (insurance_id, entity_id)
);

CREATE TABLE doctor_accreditations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
doctor_id UUID NOT NULL REFERENCES doctor_profiles(id),
insurance_id UUID NOT NULL REFERENCES insurances(id),
credential_url TEXT,
status accreditation_status NOT NULL DEFAULT 'PENDING',
UNIQUE (doctor_id, insurance_id)
);

CREATE TABLE file_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id),
bucket_path TEXT NOT NULL,
mime_type VARCHAR(80) NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES users(id),
doctor_id UUID NOT NULL REFERENCES doctor_profiles(id),
entity_id UUID NOT NULL REFERENCES health_entities(id),
insurance_id UUID REFERENCES insurances(id),
slot_start TIMESTAMPTZ NOT NULL,
slot_end TIMESTAMPTZ NOT NULL,
room_number VARCHAR(50),
status VARCHAR(20) NOT NULL CHECK (status IN ('PENDING','CONFIRMED','CANCELLED','NOSHOW')),
reason VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CHECK (slot_end > slot_start),
UNIQUE (doctor_id, slot_start, slot_end)
);

CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES users(id),
doctor_id UUID REFERENCES doctor_profiles(id),
entity_id UUID REFERENCES health_entities(id),
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
evidence_file_id UUID REFERENCES file_assets(id),
status review_status NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
action VARCHAR(50) NOT NULL,
old_values JSONB,
new_values JSONB,
ip_address INET,
trace_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Consider partitioning audit_logs and reviews by month if volume > 50M/year

CREATE TABLE coupons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_id UUID REFERENCES health_entities(id),
code VARCHAR(32) NOT NULL UNIQUE,
title VARCHAR(120) NOT NULL,
description TEXT,
discount_type coupon_discount_type NOT NULL,
discount_value NUMERIC(10,2) NOT NULL CHECK (discount_value > 0),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
max_redemptions INTEGER,
max_per_user INTEGER NOT NULL DEFAULT 1,
status coupon_status NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CHECK (ends_at > starts_at)
);

CREATE TABLE professional_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_id UUID REFERENCES health_entities(id),
title VARCHAR(180) NOT NULL,
slug VARCHAR(200) NOT NULL UNIQUE,
excerpt VARCHAR(320),
body TEXT NOT NULL,
cover_file_id UUID REFERENCES file_assets(id),
status post_status NOT NULL DEFAULT 'DRAFT',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);


CREATE TABLE notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
email_enabled BOOLEAN DEFAULT TRUE,
sms_enabled BOOLEAN DEFAULT TRUE,
push_enabled BOOLEAN DEFAULT TRUE,
marketing_opt_in BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE notification_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
channel VARCHAR(10) NOT NULL CHECK (channel IN ('EMAIL','SMS','PUSH','INAPP')),
template VARCHAR(100) NOT NULL,
status VARCHAR(10) NOT NULL CHECK (status IN ('SENT','FAILED','RETRIED')),
provider_msg_id VARCHAR(255),
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Índices sugeridos
CREATE INDEX idx_appointments_patient ON appointments (patient_id, slot_start DESC);
CREATE INDEX idx_appointments_doctor ON appointments (doctor_id, slot_start DESC);
CREATE INDEX idx_agreements_insurance ON insurance_agreements (insurance_id, entity_id);
CREATE INDEX idx_coupons_owner_status ON coupons (owner_id, status, ends_at);
CREATE INDEX idx_posts_author_status ON professional_posts (author_id, status, published_at DESC);

11. Contratos API (Swagger Draft)

El contrato OpenAPI completo se mantiene en static/openapi.yaml para generar Swagger UI. Acceso local: http://localhost:3000/openapi.yaml. Para verlo en UI, usar Swagger UI/Redocly apuntando a esa URL. Actualizar el archivo YAML vía PR cuando cambien endpoints o esquemas.