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), contramoTexto, 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 objetoaplicadade la 005 (id,estado: 'cancelada',tramoTexto,fichaTexto).- Rechazos: el catálogo único de la 005 con sus estados (
YA_CANCELADA409,ESTADO_FINAL409,CITA_AJENA403 sin revelar datos,PLAZO_VENCIDO422Ya no se puede cancelar por internet; llama a la clínica al <tel>y la cita siguereservada,ENLACE_INVALIDO401). La doble cancelación daYA_CANCELADAsin dobles efectos. GETnunca cancela (005 FR-003); estePATCHes 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: formulariocorreo+telefono→POST /api/portal/sesion(007); incorrecto (401No hemos encontrado esos datos; revísalos o llama a la clínica al <tel>), duplicado (409Tienes 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): seccionesEn curso(arriba, marca «en curso», tramo resaltado, sin botón Cancelar, con aviso de teléfono),Próximas citas(asc, botónCancelarsolo sicancelable, 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ónvisible →DELETE /api/portal/sesion→/portal/acceso.GET /portal: redirige amis-citascon sesión válida o aaccesosin 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.