Data Model — 003 Recordatorios de cita

Fuente: spec.md (Key Entities + FR-003/FR-008/FR-010/FR-011/FR-016) + Research D2/D8/D10. Dinero en céntimos INTEGER, tiempo en timestamptz, zona Europe/Madrid, idioma es-ES. Propietario de Cita/Paciente/Clinica: 001 (esta spec solo añade una tabla y una columna de enmienda).

EnvioRecordatorio (nueva, propietario 003)

Un row por envío. La re-ejecución lo detecta por UNIQUE(citaId, huella); el histórico se conserva (nunca DELETE/UPDATE del row salvo vigente derivado por lectura).

Campo Tipo Reglas
id uuid PK generado BD
citaId FK → Cita obligatorio, Restrict; índice (citaId, creadoEn)
clinicaId FK → Clinica obligatorio (desnormalizado para GET por clínica y borrado coherente en tests)
pacienteId FK → Paciente obligatorio (quién era el destinatario lógico)
emailDestino text email de la ficha en el momento del envío (raw); la huella usa el normalizado
profesionalId FK → Profesional obligatorio (parte de la configuración recordada)
servicioId FK → Servicio obligatorio (parte de la configuración recordada)
inicio / fin timestamptz tramo recordado (parte de la configuración; AAAAMMDD-HHMM del fichero sale de inicio)
huella text sha256(pacienteId \| emailNormalizado \| profesionalId \| servicioId \| inicioISO \| finISO); parte de UNIQUE(citaId, huella)
nombreFichero text recordatorio-<citaId>-AAAAMMDD-HHMM.eml; único por envío
creadoEn timestamptz instante de generación (ejecucion propagado, minuto truncado)
origen text 'auto' \| 'manual' (trazabilidad de FR-015; no cambia el comportamiento)

Constraints: UNIQUE(citaId, huella) (FR-003, concurrencia); CHECK (origen IN ('auto','manual')). El «vigente» no se persiste: es el row de mayor creadoEn por citaId; la señal FR-011 lo usa para la fecha («enviado el …»).

Huella (configuración recordada, Q6)

huella = sha256(
  pacienteId + '|' + normalizarCorreo(email) + '|' +
  profesionalId + '|' + servicioId + '|' +
  inicio.toISOString() + '|' + fin.toISOString()
)

Cualquier cambio de paciente/email/profesional/servicio/tramo → huella distinta → reenvío si el nuevo tramo cae en ventana (FR-008). Volver a un tramo ya enviado → huella ya existente → cero duplicados (edge spec).

Cita.precioCongeladoCentimos (enmienda menor 001 S-05)

Campo Tipo Reglas
precioCongeladoCentimos int NOT NULL, CHECK (>= 0); copia de Servicio.precioCentimos al reservar; inmutable después; fuente del precio del email (FR-004)

Migración: ADD COLUMN … NOT NULL DEFAULT 0 + backfill UPDATE "Cita" SET "precioCongeladoCentimos" = "Servicio"."precioCentimos" + POST /api/citas lo escribe en la misma transacción. Semilla: prisma/seed.ts lo incluye al crear citas.

Ficheros (no BD)

  • datos/salida-correo/recordatorio-<citaId>-AAAAMMDD-HHMM.eml — 1:1 con EnvioRecordatorio (SC-008). Jamás sobrescrito ni borrado.
  • datos/salida-correo/INCIDENCIAS-AAAA-MM-DD.txt — append-only: ISO-ejecucion | citaId | motivo (sin-email | email-invalido | duplicado). Sin reintento (FR-016).

Diagrama (texto)

Cita 1──N EnvioRecordatorio (citaId, huella UNIQUE)
Cita N──1 Paciente / Profesional / Servicio (configuración recordada)
EnvioRecordatorio ──1:1── .eml en datos/salida-correo/
Cita en ventana sin email válido ──0:1── línea en INCIDENCIAS-*.txt (sin row, sin .eml)