Contracts — identidad compartida (007)
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. Ningún canal redacta avisos propios (FR-008).
lib/identidad.ts (FR-001…FR-004, FR-008)
export const AVISO_IDENTIDAD_INCORRECTA =
'No hemos encontrado esos datos; revísalos o llama a la clínica';
export const AVISO_IDENTIDAD_DUPLICADA =
'Tienes varias fichas con esos datos; llama a la clínica';
export function normalizarCorreo(valor: string): string;
export function normalizarTelefono(valor: string): string;
export function correoNormalizadoEsValido(valorNormalizado: string): boolean;
export function telefonoNormalizadoEsValido(valorNormalizado: string): boolean;
export type ResolucionIdentidad =
| { estado: 'ok'; pacienteId: string }
| { estado: 'incorrecto' }
| { estado: 'duplicado' };
export function avisoIdentidad(
resolucion: 'incorrecto' | 'duplicado',
telefonoClinica: string,
): string; // '<aviso> al <telefono>'
export async function resolverPacientePorCredenciales(args: {
correo: string;
telefono: string;
clinicaId?: string;
}): Promise<ResolucionIdentidad>;
// 1) normaliza + valida (falla -> 'incorrecto'); 2) compara en memoria
// sobre fichas candidatas; 3) 0 -> 'incorrecto', >1 -> 'duplicado',
// 1 -> 'ok'. Nunca enumera fichas ni revela qué campo existe.
export type DecisionRecordatorio = { enviar: true; pacienteId: string } | { enviar: false; motivo: 'incorrecto' | 'duplicado' };
export async function decidirEnvioRecordatorio(args: {
correo: string;
telefono: string;
clinicaId?: string;
}): Promise<DecisionRecordatorio>;
// Envuelve a resolverPacientePorCredenciales para el canal de
// recordatorios (FR-004): 'incorrecto'/'duplicado' -> no enviar y anotar
// el motivo en el informe de incidencias; 'ok' -> enviar al destinatario.
lib/sesion-paciente.ts (FR-005)
export const NOMBRE_COOKIE_PORTAL = 'citaclara_portal';
export const MINUTOS_SESION_PORTAL = 30;
export function crearSesionPortal(pacienteId: string, ahora?: Date): string; // token firmado
export function verificarSesionPortal(
token: string | undefined,
ahora?: Date,
): string | null; // pacienteId o null (caducada, cerrada, firma inválida)
export function renovarSesionPortal(
token: string | undefined,
ahora?: Date,
): string | null; // nuevo token con expira deslizante o null
export function cerrarSesionPortal(token: string | undefined): void; // revoca al instante
Cookie: citaclara_portal=<token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=1800 (+ Secure en producción). Cierre/caducidad: sobrescribir vacía con Max-Age=0 y exigir reidentificación completa.
lib/enlace-verificado.ts (FR-006)
export type RechazoEnlace = 'ENLACE_INVALIDO' | 'ENLACE_CADUCADO' | 'ENLACE_BLOQUEADO';
export function crearEnlaceVerificado(args: {
citaId: string;
pacienteId: string;
inicioCita: Date;
emitidoEn?: Date; // por defecto ahora (006)
}): string; // token firmado citaId+pacienteId, expira = min(inicioCita, emitidoEn+72h)
export function verificarEnlace(
token: string | undefined,
ahora?: Date,
): { citaId: string; pacienteId: string } | { rechazo: RechazoEnlace };
export function registrarIntentoEnlace(token: string, ahora?: Date): boolean;
// false si supera 10 intentos/hora (bloqueo temporal con aviso claro)
Textos: caducado/inválido → Este enlace ya no es válido; entra al portal o llama a la clínica al <telefono> (catálogo 005 ENLACE_INVALIDO); bloqueado → Demasiados intentos con este enlace; espera una hora o llama a la clínica al <telefono>.
Rutas finas
POST /api/portal/sesion (US1/US2/US3, FR-002…FR-005)
Petición: { "correo": "…", "telefono": "…" }. Flujo: normalizar → resolver → incorrecto (401, aviso FR-003 + teléfono, sin citas) · duplicado (409, aviso FR-004 + teléfono, sin citas) · ok (200 { pacienteId } + Set-Cookie: citaclara_portal). Distingue 401/409 sin enumerar fichas.
DELETE /api/portal/sesion (US3, FR-005)
Revoca la sesión de la cookie al instante; responde 200 { "resultado": "sesion-cerrada" } y limpia la cookie. Reutilizar el token anterior → 401.
PATCH /api/citas/[id]/cancelar + token (US4, FR-006; reutiliza contrato 005)
Para origen: 'email' acepta { "origen": "email", "token": "<enlace>" } como alternativa a { "origen": "portal", "pacienteId": "<uuid>" } (portal usa sesión, no expone pacienteId libre). El handler verifica el enlace (firma + caducidad + antiflood + coincidencia citaId URL vs token) y llama a ejecutarCancelarCita con el pacienteId del token. Rechazos: token de otra cita, caducado o con flood → ENLACE_INVALIDO/ENLACE_BLOQUEADO sin mutar (mismo catálogo 005).