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.tscon 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+34lo recorta a los 9 dígitos siguientes y si empieza por0034igual; 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 alESQUEMA_TELEFONO_EStras normalizar+34), email conESQUEMA_EMAILde 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? }):- Normaliza ambas credenciales; si alguna no valida →
{ estado: 'incorrecto' }sin tocar BD más allá de lo necesario (no revela qué campo falla). - Lee las fichas candidatas (
prisma.paciente.findMany, filtrado porclinicaIdsi el canal lo conoce; si no, global) y compara en memoria con igualdad sobre valores normalizados (normalizarCorreo(p.email) === correo && normalizarTelefono(p.telefono) === telefono). - 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 }.
- Normaliza ambas credenciales; si alguna no valida →
- Rationale: FR-001 exige
pacienteIdcomo ú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 = Ydirecto 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).
R3. Sesión del portal: cookie firmada + revocación en servidor (FR-005)
- Decision:
lib/sesion-paciente.ts:- Token
base64url(pacienteId.expira.nonce).firmaHMACsha256conSESSION_SECRET(mismo patrón quelib/auth.tspara 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 }>+Setde revocados.crearSesionemite nonce aleatorio;verificarSesion(token)comprueba firma (timingSafeEqual), expira (> ahora) y presencia en el Map;renovarSesionreemite con nueva expiraahora + 30 minen cada uso (caducidad deslizante);cerrarSesionborra del Map al instante (reutilizar el token → rechazo). Limpieza perezosa de expirados en cada verificación. - Cookie
citaclara_portal(httpOnly,SameSite=Lax,Path=/,Secureen producción,Max-Age=1800); nada enlocalStorage, sin «recordar dispositivo». Tras caducidad/cierre → reidentificación completa, sin datos residuales (la cookie se sobrescribe vacía conMax-Age=0).
- Token
- 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.tsauditado. - Alternatives considered: JWT stateless sin revocación (rechazado: «Cerrar sesión» no invalidaría al instante); tabla
SesionPacienteen 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).firmaHMACsha256conSESSION_SECRET.crearEnlace({ citaId, pacienteId, inicioCita, enviadoEn, ahora })fijaexpira = min(inicioCita, enviadoEn + 72 h)(precisión de minuto,Europe/Madridví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 daYA_CANCELADA(idempotente). - Antiflood en memoria:
Map<tokenHash, number[]>con ventana deslizante de 1 h; más de 10 verificaciones/hora →BLOQUEADOtemporal con aviso claro. Limpieza perezosa. PATCH /api/citas/[id]/cancelaracepta paraorigen: 'email'el campo alternativotoken(además delpacienteIdde portal con sesión); si elcitaIdde la URL no coincide con el del token →ENLACE_INVALIDO(un enlace solo opera sobre su cita, SC-004).
- Token
- Rationale: Q3 fija atado
citaId+pacienteId, caducidadmin(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
pacienteIdsolo (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'yAVISO_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 vigentePATCHvíacancelarCitasolo 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).