Research — 007 Identidad compartida del paciente (Phase 0)

Fecha: 2026-09-30. Responde a los NEEDS CLARIFICATION del plan bajo constitución v1.0.0.

R1. Normalización única compartida (FR-002)

  • Decision: lib/identidad.ts con dos funciones puras documentadas:
    • normalizarCorreo(valor: string): string → recorta espacios extremos (trim), elimina todos los espacios interiores (\s+ → ''), pasa a minúsculas (toLowerCase). El resto del local-part se conserva (incluido +).
    • normalizarTelefono(valor: string): string → elimina espacios, guiones, puntos y paréntesis (/[\s\-().]/g → ''), luego si empieza por +34 lo recorta a los 9 dígitos siguientes y si empieza por 0034 igual; el resultado es el formato nacional de 9 dígitos. Tras normalizar se aplica la validación estricta ES heredada de la 001 (FR-004): teléfono /^[6-9]\d{8}$/ (equivalente al ESQUEMA_TELEFONO_ES tras normalizar +34), email con ESQUEMA_EMAIL de zod. Lo que no cumple → «incorrecto» (FR-003), sin revelar si existe.
    • Portal y recordatorios importan estas funciones; grep en merge verifica que no existe otra normalización (SC-005).
  • Rationale: La spec fija el algoritmo exacto en Q1. Una sola implementación evita la divergencia actual (portal normaliza, recordatorios no). Ejemplo canónico: Ana.Garcia.Lopez@correo.es → ana.garcia.lopez@correo.es; +34 612 345 678 / 0034-612-345-678 / (612) 345-678 → 612345678.
  • Alternatives considered: Normalizar en cada canal con helpers duplicados (rechazado: divergencia, viola FR-002/SC-005); columnas normalizadas persistidas en BD (rechazado: FR-009 prohíbe crear/migrar; con ~40 fichas el filtrado en memoria basta).

R2. Resolución de identidad sin fugas (FR-001/FR-003/FR-004)

  • Decision: resolverPacientePorCredenciales({ correo, telefono, clinicaId? }):
    1. Normaliza ambas credenciales; si alguna no valida → { estado: 'incorrecto' } sin tocar BD más allá de lo necesario (no revela qué campo falla).
    2. Lee las fichas candidatas (prisma.paciente.findMany, filtrado por clinicaId si el canal lo conoce; si no, global) y compara en memoria con igualdad sobre valores normalizados (normalizarCorreo(p.email) === correo && normalizarTelefono(p.telefono) === telefono).
    3. Cero coincidencias → { estado: 'incorrecto' } con aviso FR-003; más de una → { estado: 'duplicado' } con aviso FR-004, sin enumerar fichas, sin mostrar ni cancelar nada, y en recordatorios sin generar envío (se anota como duplicado en el informe de incidencias); exactamente una → { estado: 'ok', pacienteId }.
  • Rationale: FR-001 exige pacienteId como única identidad; correo + teléfono son credenciales. La comparación en memoria evita depender de collations del Postgres y de índices funcionales (sin migración por FR-009). Con la escala de la 001 (decenas de fichas) el coste es despreciable (<200 ms p95 local).
  • Alternatives considered: WHERE email = X AND telefono = Y directo en SQL (rechazado: no aplicaría la normalización y filtraría variantes equivalentes); índice único que impida duplicados (rechazado: la 001 admite duplicados y la 007 los gestiona denegando, sin fusión en v1 por FR-007).
  • Decision: lib/sesion-paciente.ts:
    • Token base64url(pacienteId.expira.nonce).firmaHMACsha256 con SESSION_SECRET (mismo patrón que lib/auth.ts para recepción; secreto distinto no necesario, misma rotación: rotar no invalida más allá de la caducidad natural de 30 min).
    • Estado en memoria: Map<nonce, { pacienteId, expira }> + Set de revocados. crearSesion emite nonce aleatorio; verificarSesion(token) comprueba firma (timingSafeEqual), expira (> ahora) y presencia en el Map; renovarSesion reemite con nueva expira ahora + 30 min en cada uso (caducidad deslizante); cerrarSesion borra del Map al instante (reutilizar el token → rechazo). Limpieza perezosa de expirados en cada verificación.
    • Cookie citaclara_portal (httpOnly, SameSite=Lax, Path=/, Secure en producción, Max-Age=1800); nada en localStorage, sin «recordar dispositivo». Tras caducidad/cierre → reidentificación completa, sin datos residuales (la cookie se sobrescribe vacía con Max-Age=0).
  • Rationale: Q2 exige firma con secreto, deslizamiento de 30 min, invalidación instantánea y cero persistencia. Un token puramente stateless no puede invalidarse al instante sin lista de revocación; como FR-009 prohíbe tablas, la memoria de proceso es la vía mínima (escala de clínica pequeña, un contenedor Node). El patrón HMAC reutiliza lib/auth.ts auditado.
  • Alternatives considered: JWT stateless sin revocación (rechazado: «Cerrar sesión» no invalidaría al instante); tabla SesionPaciente en Postgres (rechazado: FR-009 «no crea tablas»); localStorage (rechazado: prohibido por FR-005).

R4. Enlace verificado que cierra la deuda (FR-006)

  • Decision: lib/enlace-verificado.ts:
    • Token base64url(citaId.pacienteId.expira.emitido).firmaHMACsha256 con SESSION_SECRET. crearEnlace({ citaId, pacienteId, inicioCita, enviadoEn, ahora }) fija expira = min(inicioCita, enviadoEn + 72 h) (precisión de minuto, Europe/Madrid vía primitivas 006). verificarEnlace(token, { ahora }) comprueba firma, expira y que la cita siga existiendo; devuelve { citaId, pacienteId } o un rechazo (EXPIRADO/INVALIDO/BLOQUEADO) sin mutar nada.
    • Visualizar la confirmación (GET) no muta; la acción ejecuta ejecutarCancelarCita({ pacienteIdVerificado: pacienteId del token, citaId del token, origen: 'email' }): idempotencia y reglas de estados las pone la 005. El token es reutilizable para ver, pero la segunda confirmación da YA_CANCELADA (idempotente).
    • Antiflood en memoria: Map<tokenHash, number[]> con ventana deslizante de 1 h; más de 10 verificaciones/hora → BLOQUEADO temporal con aviso claro. Limpieza perezosa.
    • PATCH /api/citas/[id]/cancelar acepta para origen: 'email' el campo alternativo token (además del pacienteId de portal con sesión); si el citaId de la URL no coincide con el del token → ENLACE_INVALIDO (un enlace solo opera sobre su cita, SC-004).
  • Rationale: Q3 fija atado citaId + pacienteId, caducidad min(inicio, 72 h), reutilizable-para-ver con acción idempotente y antiflood 10/h. Cierra la deuda FR-017/Q9 de la 003 sin cambiar el comportamiento observable de la 003 salvo la verificación. Memoria en proceso basta (decenas de enlaces/hora; sin tablas por FR-009 e IV).
  • Alternatives considered: Token con pacienteId solo (rechazado: operaría sobre cualquier cita; viola SC-004); persistir enlaces en BD (rechazado: FR-009 + alcance fantasma); antiflood por IP (rechazado: el límite es por enlace según spec).

R5. Sin fusión en v1 + catálogo único (FR-007/FR-008)

  • Decision: Ninguna función fusiona, reasigna ni mezcla fichas/citas/envíos; la colisión correo + teléfono se resuelve siempre denegando (US2). Constantes AVISO_IDENTIDAD_INCORRECTA = 'No hemos encontrado esos datos; revísalos o llama a la clínica' y AVISO_IDENTIDAD_DUPLICADA = 'Tienes varias fichas con esos datos; llama a la clínica', siempre con el teléfono de la clínica (Clinica.telefono, 001 FR-001) interpolado en el canal (… al <telefono>). Ningún canal redacta variantes (verificación por grep, SC-005).
  • Rationale: Q4/Q5 lo fijan: v1 sin fusión (ni auto ni manual), citas e históricos nunca se reasignan; la fusión va en spec posterior. Q5 fija los dos avisos únicos para todos los canales.
  • Alternatives considered: Fusión manual por recepción en v1 (rechazado: fuera de alcance, FR-007); avisos por canal (rechazado: viola FR-008).

R6. Estrategia de tests (VI + III)

  • Decision: Vitest unit (test_identidad: matriz de variantes del spec + no-válidos como «incorrecto»; test_sesion_paciente: 30 min sin actividad exige reidentificación, cierre invalida al instante, cero restos; test_enlace: vigente opera, caducado por inicio y por 72 h rechaza, >10/h bloquea) + integración (test_identidad_canales: gemelas → portal deniega sin citas, enlace deniega, recordatorios no envían y anotan duplicado; enlace vigente PATCH vía cancelarCita solo sobre su cita; reintento tras caducidad no muta).
  • Rationale: Constitución VI (cada FR con test trazable) y III (cero fugas entre fichas como defecto crítico: 100 % denegado en SC-002). La matriz verifica SC-001 en ambos canales con el mismo algoritmo.
  • Alternatives considered: Solo e2e Playwright (rechazado: caducidades de 30 min/72 h y flood no se prueban de forma fiable ni rápida en e2e; el contrato es de librería + handlers).