Data Model — 005 Cancelación por el paciente

Fuente: spec.md (FR-001…FR-009). Propietario de la tabla: 001 (esta feature solo escribe por delegación). Zona Europe/Madrid, idioma es-ES, dinero intacto.

Sin tablas nuevas (principio IV)

Esta feature no crea tablas ni endpoints de lectura propios. Reutiliza Cita, Paciente y Clinica de la 001.

Enmienda menor a la 001 (dos columnas + teléfono pendiente)

Columna Tipo Reglas
Cita.cancelacionOrigen text NULL portal \| email; solo informada cuando estado = 'cancelada' vía cancelarCita; NULL en el resto de estados y en cancelaciones de recepción
Cita.canceladaEn timestamptz NULL instante ahora de la transacción que aplicó, truncado a minuto; NULL salvo cancelación por paciente
Clinica.telefono text NOT NULL pendiente de la enmienda S-01 jul2026 (verificada ausente en el esquema actual); contacto mostrado en los avisos ESTADO_FINAL, PLAZO_VENCIDO, ENLACE_INVALIDO; la semilla Eleva lo incluye

Migración Prisma: prisma/migrations/XXXX_005_cancelacion_paciente/migration.sql (ADD COLUMN con DEFAULT '910123456' para Clinica.telefono + UPDATE de backfill en filas existentes antes de SET NOT NULL; semilla Eleva actualizada).

Ejecución de cancelarCita (no persistente como entidad)

Campo Tipo Reglas
pacienteId uuid verificado según 007 antes de invocar; null/no verificado → ENLACE_INVALIDO sin tocar BD
citaId uuid cita objetivo
origen portal \| email cualquier otro valor → invocación inválida (422, sin mutar)
resultado aplicada \| YA_CANCELADA \| ESTADO_FINAL \| CITA_AJENA \| PLAZO_VENCIDO \| ENLACE_INVALIDO exactamente un valor por ejecución; los rechazos no mutan nada

Orden de validación dentro de la transacción (tras capturarAhora())

  1. Identidad no verificada → ENLACE_INVALIDO (antes de leer la cita).
  2. Cita inexistente en la clínica → ENLACE_INVALIDO si el origen es email (no revelar existencia), CITA_AJENA nunca revela datos de otra ficha; en portal con sesión válida la cita inexistente se trata igual sin enumerar.
  3. cita.pacienteId !== pacienteId → CITA_AJENA.
  4. estado === 'cancelada' → YA_CANCELADA; estado ∈ {completada, no_asistida} → ESTADO_FINAL.
  5. cancelacionEnPlazo(inicio, ahora) === false → PLAZO_VENCIDO (006 FR-005, borde 24 h incluido, minuto).
  6. En otro caso → UPDATE … SET estado='cancelada', cancelacionOrigen, canceladaEn=ahora WHERE id AND estado='reservada'; si count === 0 (carrera perdida) → releer y devolver YA_CANCELADA/ESTADO_FINAL.

Reglas de negocio en BD + aplicación

  • Atomicidad (FR-006): transacción serializable, reintento ≤3 ante P2034; escritura condicional como árbitro de la carrera. La exclusión RN1 (Cita_sin_solapes) no interviene: pasar a cancelada sale del predicado y libera el tramo [inicio, fin) para RN1 (FR-007).
  • Idempotencia: la segunda ejecución sobre la misma cita lee cancelada y devuelve YA_CANCELADA.
  • Trazabilidad (FR-008): ficha muestra «Cancelada por el paciente desde el portal/email el DD/MM/AAAA a las HH:MM» (cancelacionOrigen + formatearFechaHora(canceladaEn)). Los rechazos solo van al log del servidor (origen, instante, código).

Diagrama (texto)

Portal / Email --cancelarCita(pacienteId verificado, citaId, origen)--> lib/cancelacion.ts
  --tx serializable--> Cita(reservada -> cancelada + origen + instante)
  --rechazo--> mismo estado, aviso único con Clinica.telefono
Cita cancelada --sale del predicado--> RN1 libre para POST /api/citas