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).