Data Model — 007 Identidad compartida del paciente

Fuente: spec.md (FR-001…FR-009). Propietario de las tablas: 001 (esta feature no crea ni migra nada). Zona Europe/Madrid, idioma es-ES.

Sin tablas nuevas ni migraciones (principios IV + FR-009)

Esta feature reutiliza Paciente (id, clinicaId, nombre, telefono, email), Cita (id, pacienteId, inicio, estado) y Clinica (id, telefono) tal cual. No hay ALTER TABLE, no hay backfill, no hay fusión ni reasignación en v1 (FR-007).

Entidades lógicas (no persistentes)

Entidad Campos Reglas
Credenciales correo: string (crudo del formulario), telefono: string (crudo) nunca son identidad; solo entrada a resolverPacientePorCredenciales; se normalizan antes de comparar (FR-002)
Identidad pacienteId: uuid (ficha de la 001) única identidad en todos los canales (FR-001); cada ficha ve solo sus citas; dos fichas con mismo nombre pero distinto correo/teléfono son identidades distintas
Resolucion ok { pacienteId } \| incorrecto \| duplicado incorrecto: cero coincidencias o no normaliza a formato válido → aviso FR-003, sin revelar qué campo existe, sin mostrar citas; duplicado: >1 ficha con el mismo par normalizado → aviso FR-004, sin enumerar fichas, sin mostrar ni cancelar, sin generar recordatorio (se anota como duplicado en incidencias)
SesionPortal (memoria) nonce: string, pacienteId: uuid, expira: Date (ahora + 30 min, renovada con cada uso) cookie firmada citaclara_portal; revocación en servidor al instante con «Cerrar sesión»; nada en localStorage; tras caducidad/cierre → reidentificación completa (FR-005)
EnlaceVerificado (memoria salvo token) citaId: uuid, pacienteId: uuid, emitidoEn: Date, expira: Date = min(inicioCita, emitidoEn + 72 h) token firmado; ver sin sesión solo muestra su cita; la acción ejecuta cancelarCita (005) con pacienteId del token; antiflood 10 intentos/hora por enlace → bloqueo temporal (FR-006)

Normalización documentada (FR-002, algoritmo único)

normalizarCorreo(x)   = minusculas(eliminarEspaciosInteriores(recortar(x)))
normalizarTelefono(x) = digitos9(eliminar([espacio, '-', '.', '(', ')'], x)
                          con '+34'/'0034' -> 9 digitos nacionales)
validarTrasNormalizar = ESQUEMA_EMAIL(zod) para correo
                        AND /^[6-9]\d{8}$/ para telefono (forma normalizada
                        de ESQUEMA_TELEFONO_ES de la 001)

Ejemplos: Ana.Garcia.Lopez@correo.es → ana.garcia.lopez@correo.es; +34 612 345 678, 0034-612-345-678, (612) 345.678 → 612345678. 9 dígitos que no empiezan por 6/7/8/9 → «incorrecto».

Resolución (orden vinculante, sin fugas)

  1. Normalizar + validar → si falla, incorrecto (antes de distinguir).
  2. Comparar en memoria sobre fichas candidatas con igualdad normalizada.
  3. 0 → incorrecto (aviso FR-003 + teléfono clínica). ≥2 → duplicado (aviso FR-004 + teléfono; recordatorios: sin envío + incidencia duplicado). 1 → ok(pacienteId).
  4. Con ok, todos los accesos posteriores filtran por pacienteId (portal: cita.pacienteId === sesion.pacienteId; email: citaId del token + pacienteId del token vía cancelarCita, que devuelve CITA_AJENA si no coincide).

Sesión y enlace (estado en memoria, caducidad)

  • Sesión: Map<nonce, { pacienteId, expira }>; limpieza perezosa de expirados; firma HMAC-sha256 con SESSION_SECRET verificada con timingSafeEqual (patrón lib/auth.ts).
  • Enlace: Map<hashToken, number[]> (instantes de intento, ventana 1 h, tope 10); firma HMAC-sha256 con SESSION_SECRET; expira = min(inicioCita, emitidoEn + 72 h) a minuto (primitivas 006).

Diagrama (texto)

Portal: credenciales --normalizar--> resolver --> ok(pacienteId)
          --> crearSesion (cookie firmada 30min) --> citas WHERE pacienteId
          --> incorrecto/duplicado --> aviso unico, sin citas
Email:  cita --crearEnlace(citaId+pacienteId, min(inicio, +72h))--> token
          --> verificarEnlace (firma+caducidad+antiflood) --> cancelarCita(token)
Recordatorios: destinatario --normalizar--> resolver --> duplicado? no enviar + incidencia

Reglas de negocio que esta feature NO toca

Transiciones (reservada → cancelada en 005), ventanas y «ahora» (006), métricas/envíos/auth de recepción (FR-009). La fusión de fichas queda fuera para spec posterior (FR-007).