Research — 005 Cancelación por el paciente (Phase 0)

Fecha: 2026-09-30. Responde a los NEEDS CLARIFICATION del plan bajo constitución v1.0.0.

R1. Dónde vive cancelarCita: módulo de dominio + Route Handler fino

  • Decision: lib/cancelacion.ts (función pura decidirCancelacion + ejecutarCancelarCita con Prisma) y un único Route Handler PATCH /api/citas/[id]/cancelar que lo invoca. La ruta de recepción existente PATCH /api/citas/[id]/estado no se toca.
  • Rationale: La spec exige caso de uso único invocable desde portal y email (FR-001, SC-004). Un módulo importable por ambos canales más una ruta fina evita duplicar la transición reservada → cancelada. Sigue el patrón ya usado (lib/agenda.ts + app/api/citas/route.ts).
  • Alternatives considered: Lógica dentro del Route Handler (rechazado: portal y email no podrían reutilizarla sin duplicar); servicio separado con Repository pattern (rechazado: alcance fantasma, principio IV; Prisma directo basta como en la 001).

R2. Atomicidad concurrente: transacción serializable + escritura condicional

  • Decision: Transacción Prisma serializable que lee cita + clínica y escribe cancelada en la misma transacción; reintento hasta 3 veces ante P2034; la escritura usa updateMany({ where: { id, estado: 'reservada' } }) y si count === 0 relee el estado vigente para devolver YA_CANCELADA o ESTADO_FINAL.
  • Rationale: FR-006 exige como máximo una aplicada ante doble clic / doble petición / carrera con recepción, sin estados intermedios. Es el mismo patrón que POST /api/citas (plan 001, exclusión + serializable). La restricción de exclusión RN1 no bloquea cancelaciones (salir del predicado reservada/completada siempre es seguro), así que la garantía aquí es la escritura condicional, no un nuevo constraint.
  • Alternatives considered: Lock pesimista SELECT FOR UPDATE explícito (rechazado: Prisma no lo expone de forma portable; serializable + reintento logra lo mismo); cola o Redis lock (rechazado: alcance fantasma IV para 2–5 profesionales).

R3. Tiempo: reutilizar lib/ventanas.ts, cero redefiniciones

  • Decision: cancelacionEnPlazo(inicio, ahora) (006 FR-005, borde 24 h incluido, minuto truncado) y capturarAhora() (006 FR-001) como únicas primitivas temporales. ENLACE_INVALIDO se devuelve antes de cualquier otra validación cuando el pacienteId no viene verificado.
  • Rationale: FR-004 y 006 FR-010 prohíben redefinir «ahora», bordes o precisión. Orden de validación: ENLACE_INVALIDO (identidad no verificada) → CITA_AJENA (cita de otra ficha, sin revelar datos) → YA_CANCELADA / ESTADO_FINAL → PLAZO_VENCIDO. Este orden evita filtrar existencia de citas ajenas y respeta el edge de identidad.
  • Alternatives considered: Recalcular inicio − ahora localmente en el módulo (rechazado: defecto según 006 FR-010).

R4. Trazabilidad sin tabla nueva: dos columnas anulables en Cita

  • Decision: Enmienda menor a la 001 (propietario 001, procedimiento Governance): Cita.cancelacionOrigen TEXT NULL (portal|email) + Cita.canceladaEn TIMESTAMPTZ NULL, escritas en la misma transacción que el paso a cancelada. Los rechazos no persisten en BD (solo log de servidor con origen, instante y código). La ficha muestra «Cancelada por el paciente desde el portal/email el DD/MM/AAAA a las HH:MM» derivando de esas columnas con formatearFechaHora (Europe/Madrid).
  • Rationale: FR-008 pide traza visible solo para cancelaciones aplicadas; una tabla de auditoría completa es alcance fantasma (IV). Dos columnas anulables son la extensión mínima y mantienen la propiedad de la tabla en la 001.
  • Alternatives considered: Tabla CancelacionLog con una fila por intento (rechazado: sobredimensionado para v1; los rechazos quedan en log); reutilizar createdAt (rechazado: es creación, no cancelación).

R5. Catálogo único + Clínica.telefono pendiente (enmienda S-01)

  • Decision: Catálogo en lib/cancelacion.ts (AVISOS_CANCELACION), textos es-ES con el teléfono de la clínica interpolado; Clínica.telefono se añade en la misma migración 005 como parte pendiente de la enmienda S-01 jul2026 (columna TEXT NOT NULL, semilla Eleva con número español verosímil, p. ej. 910123456). Los cinco códigos: YA_CANCELADA, ESTADO_FINAL, CITA_AJENA, PLAZO_VENCIDO, ENLACE_INVALIDO.
  • Rationale: FR-005 exige teléfono en tres avisos y el esquema actual no tiene Clinica.telefono (verificado en prisma/schema.prisma y migración 0001). Sin esta columna SC-005 es imposible. Se documenta como enmienda a la 001 con propietario agenda y recepción.
  • Alternatives considered: Teléfono hardcodeado o en variable de entorno (rechazado: la spec y MAPA lo declaran propiedad de la 001 por clínica).

R6. Estrategia de tests (VI + III)

  • Decision: Vitest unit (tests/unit/test_cancelacion.test.ts: catálogo, bordes 24 h 00 min dentro / 23 h 59 min fuera, matriz de rechazos sin mutación) + integración (tests/integration/test_cancelacion_paciente.test.ts: aplicada libera tramo RN1 reutilizable, Promise.all doble cancelación → exactamente una aplicada, carrera con completada de recepción → perdedora con ESTADO_FINAL/YA_CANCELADA).
  • Rationale: Constitución VI (cada regla con test trazable) y III (concurrencia en verde para fusionar). La matriz de rechazos verifica estado inmutable y aviso exacto.
  • Alternatives considered: Solo e2e Playwright (rechazado: la atomicidad concurrente no se prueba de forma fiable en e2e; el contrato es de caso de uso, no de página).