Contracts — portal del paciente (002)

Base: app Next.js existente. Textos en español de España. Errores: { "error": "<texto único>", "codigo": "<CÓDIGO>" }. Sin importes en v1 (FR-013). Tiempo solo vía 006, identidad solo vía 007, transición solo vía 005.

lib/portal.ts (FR-001/FR-002, proyección pura)

export type EstadoVisiblePortal =
  | 'reservada' | 'en curso' | 'completada' | 'cancelada' | 'no asistida';

export type FilaPortal = {
  id: string;
  inicio: Date;
  fin: Date;
  servicioNombre: string;
  profesionalNombre: string;
  estadoVisible: EstadoVisiblePortal;
  tramoTexto: string; // 'DD/MM/AAAA, HH:MM–HH:MM' (formatearTramo, Europe/Madrid)
  cancelable: boolean; // solo proximas con cancelacionEnPlazo(inicio, ahora)
};

export type VistaPortal = {
  enCurso: FilaPortal[];
  proximas: FilaPortal[];
  anteriores: FilaPortal[];
};

export function clasificarCitasDePortal(
  citas: Array<{
    id: string; inicio: Date; fin: Date; estado: string;
    servicioNombre: string; profesionalNombre: string;
  }>,
  ahora: Date, // de capturarAhora(), 006 FR-001; se trunca a minuto dentro
): VistaPortal;
// Partición exhaustiva sin solapes (FR-001); proximas asc, anteriores desc;
// 'reservada' con fin pasado va a anteriores sin mutar su estado en BD.

GET /api/portal/citas → 200 | 401 (FR-003/FR-005, US1/US3)

Auth: cookie citaclara_portal verificada con verificarSesionPortal (007 FR-005); en cada uso válido se renueva (renovarSesionPortal) y se reemite la cookie (Max-Age=1800).

  • 200: { "enCurso": FilaPortal[], "proximas": FilaPortal[], "anteriores": FilaPortal[], "telefonoClinica": "910…" } — solo citas de la ficha de la sesión (WHERE pacienteId), con tramoTexto, servicio, profesional y estado en lenguaje de paciente; ningún importe.
  • 401 SIN_SESION: { "error": "Tu sesión ha terminado; entra de nuevo con tu correo y tu teléfono", "codigo": "SIN_SESION" } + cookie limpiada — tras caducidad (30 min sin actividad), cierre o firma inválida; exige reidentificación completa.

PATCH /api/portal/citas/[id]/cancelar → 200 | 401 | 403 | 409 | 422 (FR-006…FR-009, US2)

Petición: { "confirmar": true } (sin pacienteId: se deriva de la sesión del servidor). Flujo: verificar sesión → ejecutarCancelarCita({ pacienteIdVerificado, citaId: id, origen: 'portal' }) (005). Confirmación expresa previa obligatoria en UI; sin confirmar: true → 422 SIN_CONFIRMAR sin mutar.

  • 200 APLICADA: el objeto aplicada de la 005 (id, estado: 'cancelada', tramoTexto, fichaTexto).
  • Rechazos: el catálogo único de la 005 con sus estados (YA_CANCELADA 409, ESTADO_FINAL 409, CITA_AJENA 403 sin revelar datos, PLAZO_VENCIDO 422 Ya no se puede cancelar por internet; llama a la clínica al <tel> y la cita sigue reservada, ENLACE_INVALIDO 401). La doble cancelación da YA_CANCELADA sin dobles efectos.
  • GET nunca cancela (005 FR-003); este PATCH es la única escritura del portal (verificación SC-004 por grep).

UI app/portal/ (FR-002/FR-006/FR-010/FR-011)

  • GET /portal/acceso: formulario correo + telefono → POST /api/portal/sesion (007); incorrecto (401 No hemos encontrado esos datos; revísalos o llama a la clínica al <tel>), duplicado (409 Tienes varias fichas con esos datos; llama a la clínica al <tel>); ok → redirige a /portal/mis-citas. Sin enumerar fichas, sin mostrar citas antes de entrar.
  • GET /portal/mis-citas (requiere sesión; sin ella redirige a /portal/acceso): secciones En curso (arriba, marca «en curso», tramo resaltado, sin botón Cancelar, con aviso de teléfono), Próximas citas (asc, botón Cancelar solo si cancelable, con diálogo ¿Seguro que quieres cancelar esta cita? Se liberará tu hueco), Citas anteriores (desc). Cada fila: tramoTexto · servicio · profesional · estadoVisible. Cero importes. Cerrar sesión visible → DELETE /api/portal/sesion → /portal/acceso.
  • GET /portal: redirige a mis-citas con sesión válida o a acceso sin ella.
  • Responsive: legible y operable a 390 px sin zoom ni scroll horizontal y correcta a 1440 px; botones min-h-11; contraste y tamaños heredados.

Esquemas zod (en lib/validacion.ts)

ESQUEMA_CONFIRMAR_CANCELACION_PORTAL = z.object({
  confirmar: z.literal(true, { message: 'Confirma la cancelación para continuar' }),
});

Los esquemas de acceso a correo/teléfono los pone 007 (POST /api/portal/sesion); los de cancelación los pone 005 (ESQUEMA_CANCELACION_PACIENTE para el caso de uso). Este portal no define normalización, bordes ni catálogos propios.