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 puradecidirCancelacion+ejecutarCancelarCitacon Prisma) y un único Route HandlerPATCH /api/citas/[id]/cancelarque lo invoca. La ruta de recepción existentePATCH /api/citas/[id]/estadono 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
serializableque lee cita + clínica y escribecanceladaen la misma transacción; reintento hasta 3 veces anteP2034; la escritura usaupdateMany({ where: { id, estado: 'reservada' } })y sicount === 0relee el estado vigente para devolverYA_CANCELADAoESTADO_FINAL. - Rationale: FR-006 exige como máximo una
aplicadaante doble clic / doble petición / carrera con recepción, sin estados intermedios. Es el mismo patrón quePOST /api/citas(plan 001, exclusión + serializable). La restricción de exclusión RN1 no bloquea cancelaciones (salir del predicadoreservada/completadasiempre es seguro), así que la garantía aquí es la escritura condicional, no un nuevo constraint. - Alternatives considered: Lock pesimista
SELECT FOR UPDATEexplí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) ycapturarAhora()(006 FR-001) como únicas primitivas temporales.ENLACE_INVALIDOse devuelve antes de cualquier otra validación cuando elpacienteIdno 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 − ahoralocalmente 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 acancelada. Los rechazos no persisten en BD (solo log de servidor conorigen, 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 conformatearFechaHora(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
CancelacionLogcon una fila por intento (rechazado: sobredimensionado para v1; los rechazos quedan en log); reutilizarcreatedAt(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.telefonose añade en la misma migración 005 como parte pendiente de la enmienda S-01 jul2026 (columnaTEXT 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 enprisma/schema.prismay migración0001). 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:aplicadalibera tramo RN1 reutilizable,Promise.alldoble cancelación → exactamente unaaplicada, carrera concompletadade recepción → perdedora conESTADO_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).