Data Model — 002 Portal del paciente

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

Sin tablas nuevas ni migraciones (principio IV + FR-015/FR-016)

Reutiliza tal cual Paciente (id, nombre, telefono, email), Cita (id, pacienteId, profesionalId, servicioId, inicio, fin, estado), Profesional (nombre), Servicio (nombre) y Clinica (telefono). No hay ALTER TABLE, no hay backfill, no hay cuentas ni tokens persistidos (la sesión vive en memoria según 007 FR-005).

Entidades lógicas (no persistentes)

Entidad Campos Reglas
VistaPortal enCurso: FilaPortal[], proximas: FilaPortal[], anteriores: FilaPortal[], telefonoClinica: string proyección de lectura sobre las citas de una sola ficha (pacienteId verificado); cada cita aparece en exactamente una sección (FR-001)
FilaPortal id, inicio, fin, servicioNombre, profesionalNombre, estadoVisible (reservada|en curso|completada|cancelada|no asistida), tramoTexto (DD/MM/AAAA, HH:MM–HH:MM), cancelable: boolean sin importes (FR-013); cancelable solo si proximas y cancelacionEnPlazo(inicio, ahora) (006 FR-005); «En curso» nunca cancelable (FR-006)
SesionPortal ver 007 FR-005 cookie firmada citaclara_portal, 30 min deslizantes, «Cerrar sesión» instantáneo; tras caducidad/cierre → reidentificación (FR-003)

Clasificación (orden vinculante, proyección de lectura)

ahora = capturarAhora() una vez por operación (006 FR-001), todo truncado a minuto (006 FR-002)
para cada cita de la ficha (WHERE pacienteId):
  si estado == 'reservada' y inicio <= ahora < fin  → enCurso
  si estado == 'reservada' y inicio > ahora          → proximas
  en otro caso                                       → anteriores
      (completada/cancelada/no_asistida siempre; reservada con fin <= ahora también)
orden: enCurso asc por inicio · proximas asc por inicio · anteriores desc por inicio
etiqueta: enCurso→«en curso»; proximas→«reservada»; anteriores→«completada»/«cancelada»/«no asistida»/«reservada»(fin pasado)

Bordes: inicio == ahora → en curso (no es pasado RN2 pero tampoco tiene 24 h de antelación: no cancelable); fin == ahora → anteriores (el intervalo es [inicio, fin)); inicio − ahora == 24 h 00 min → cancelable, 23 h 59 min → no (006 FR-005). Una reservada con fin pasado sigue siendo reservada en BD (001): solo se proyecta en «Anteriores».

Cancelación (escritura delegada, sin transición propia)

Solo ejecutarCancelarCita({ pacienteIdVerificado de la sesión, citaId, origen: 'portal' }) (005 FR-001). La UI exige confirmación expresa previa (¿Seguro que quieres cancelar esta cita? Se liberará tu hueco); visualizar (GET) nunca escribe (005 FR-003). Efecto: reservada → cancelada + tramo [inicio, fin) libre para recepción (FR-009, RN1). Rechazos con el catálogo único de la 005 (YA_CANCELADA, ESTADO_FINAL, CITA_AJENA, PLAZO_VENCIDO, ENLACE_INVALIDO), con teléfono de la clínica en los avisos.

Diagrama (texto)

Acceso: credenciales --resolverPacientePorCredenciales (007)--> ok(pacienteId)
          --> crearSesionPortal (cookie 30min) --> GET /api/portal/citas WHERE pacienteId
          --> clasificarCitasDePortal (lib/portal.ts) --> enCurso/proximas/anteriores
Cancelar: confirmar --> PATCH /api/portal/citas/[id]/cancelar (sesion -> pacienteId)
          --> ejecutarCancelarCita-origen-portal (005) --> cancelada + tramo libre
Cerrar: DELETE /api/portal/sesion --> revoca + limpia cookie --> reidentificacion

Reglas que esta feature NO toca

Normalización/duplicados/sesión/enlace (007), «ahora»/bordes/ventanas (006), transición/catálogo/atomicidad/traza (005), alta/desenlace/antisolape (001). Definir cualquiera localmente es defecto.