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 defichaTexto: literalCancelada por el paciente desde el+ (portal|email) +el+formatearDia(canceladaEn)+a las+formatearHora(canceladaEn)(noformatearFechaHora, 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 conorigenfuera deportal|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.