Contracts — cancelarCita (caso de uso único del paciente)

Base: app Next.js. Textos en español de España. Errores: { "error": "<texto único>", "codigo": "<CÓDIGO>" } con el teléfono de la clínica interpolado donde aplique.

Función de dominio lib/cancelacion.ts

type OrigenCancelacion = 'portal' | 'email';
type ResultadoCancelacion =
  | { resultado: 'aplicada' }
  | { resultado: 'YA_CANCELADA' | 'ESTADO_FINAL' | 'CITA_AJENA' | 'PLAZO_VENCIDO' | 'ENLACE_INVALIDO' };

// Pura (sin BD): decide a partir del estado vigente + ahora capturado.
decidirCancelacion(args: {
  cita: { pacienteId: string; estado: Estado; inicio: Date } | null;
  pacienteIdVerificado: string | null;
  origen: OrigenCancelacion;
  ahora: Date; // de capturarAhora(), 006 FR-001
  telefonoClinica: string;
}): ResultadoCancelacion;

// Con BD: transacción serializable, escritura condicional, traza.
ejecutarCancelarCita(args: {
  pacienteIdVerificado: string | null;
  citaId: string;
  origen: OrigenCancelacion;
  ahora?: Date; // por defecto capturarAhora()
}): Promise<ResultadoCancelacion & { telefonoEnAviso?: string }>;

PATCH /api/citas/[id]/cancelar → 200 | 401 | 403 | 404 | 409 | 422 (US1/US2, FR-001…FR-005)

Petición: { "pacienteId": "uuid", "origen": "portal" | "email" }. La identidad debe venir verificada según 007 (sesión del portal o token de enlace); sin verificación → ENLACE_INVALIDO.

  • 200 APLICADA: { "resultado": "aplicada", "id": "uuid", "estado": "cancelada", "origen": "portal|email", "tramoTexto": "05/10/2026, 10:00–10:45", "fichaTexto": "Cancelada por el paciente desde el portal el 01/10/2026 a las 09:15" }. Composición de fichaTexto: literal Cancelada por el paciente desde el + (portal|email) + el + formatearDia(canceladaEn) + a las + formatearHora(canceladaEn) (no formatearFechaHora, que usa coma).
  • 409 YA_CANCELADA: Esta cita ya está cancelada (doble clic, reintento, carrera perdida entre pacientes).
  • 409 ESTADO_FINAL: Esta cita ya no se puede cancelar; llama a la clínica al <telefono> (completada/no_asistida, o carrera perdida contra recepción).
  • 403 CITA_AJENA: No se ha encontrado esa cita para tu ficha; revísala o llama a la clínica al <telefono> (genérico, sin revelar datos de otra ficha; mismo texto busque o no la cita).
  • 422 PLAZO_VENCIDO: Ya no se puede cancelar por internet; llama a la clínica al <telefono> (inicio − ahora < 24 h, borde 24 h incluido, minuto).
  • 401/404 ENLACE_INVALIDO: Este enlace ya no es válido; entra al portal o llama a la clínica al <telefono> (sin sesión/token válido o cita inexistente por este canal; no revela existencia).
  • 422 ORIGEN_INVALIDO: invocación con origen fuera de portal|email; sin mutar nada.

Contrato: visualizar la página o el enlace (GET) nunca cancela; solo este PATCH con confirmación expresa escribe cancelada. Portal y recordatorios llaman aquí y no implementan su propia transición (SC-004, verificación por grep en merge).

Esquemas zod (en lib/validacion.ts)

ESQUEMA_CANCELACION_PACIENTE = z.object({
  pacienteId: z.string().uuid(),
  origen: z.enum(['portal', 'email']),
});

Los textos del catálogo viven en lib/cancelacion.ts (AVISOS_CANCELACION); ningún canal redacta los suyos.