Research — 002 Portal del paciente (Phase 0)

Fecha: 2026-10-01. Responde a los NEEDS CLARIFICATION del plan bajo constitución v1.0.0. Consume 001 (agenda), 005 (cancelarCita), 006 (tiempo) y 007 (identidad); no redefine ninguno.

R1. Clasificación En curso / Próximas / Anteriores: proyección pura en lib/portal.ts

  • Decision: función pura clasificarCitasDePortal(citas, ahora) en lib/portal.ts, sin BD ni estado:
    • enCurso = estado === 'reservada' && inicio <= ahora < fin (truncado a minuto, primitivas 006).
    • proximas = estado === 'reservada' && inicio > ahora, orden asc por inicio.
    • anteriores = todo lo demás (completada/cancelada/no_asistida + reservada con fin <= ahora), orden desc por inicio.
    • Exhaustiva y sin solapes: cada cita cae en exactamente una sección (verificación por test de partición).
    • Etiqueta visible por fila: reservada→«reservada», en curso→«en curso», resto→«completada»/«cancelada»/«no asistida» (sin jerga, sin importes en v1 por FR-013).
  • Rationale: FR-001 exige que la clasificación sea proyección de lectura sobre el estado reservada de la 001 (ver 001 FR-011), no estados nuevos. Una función pura es testeable con reloj controlado y reutilizable por API y UI sin duplicar bordes.
  • Alternatives considered: clasificar en SQL con CASE WHEN (rechazado: duplicaría los bordes de 006 fuera de lib/ventanas.ts, defecto según 006 FR-010); tres queries separadas (rechazado: riesgo de solape/hueco entre secciones).

R2. Lectura y escritura del portal siempre con sesión, nunca con pacienteId del cliente

  • Decision:
    • GET /api/portal/citas lee la cookie citaclara_portal, la verifica con verificarSesionPortal (007), renueva con renovarSesionPortal (deslizante 30 min) y devuelve { enCurso, proximas, anteriores, telefonoClinica } filtrando WHERE pacienteId = sesion.pacienteId. Sin sesión válida → 401 con reidentificación (mismo aviso de sesión caducada, sin revelar datos).
    • PATCH /api/portal/citas/[id]/cancelar (fachada fina nueva) verifica la sesión, deriva pacienteIdVerificado del servidor y llama a ejecutarCancelarCita({ pacienteIdVerificado, citaId, origen: 'portal' }) (005). El cuerpo solo trae { confirmar: true }; se ignora cualquier pacienteId del cliente. Rechazos con el catálogo único de la 005 (incluido PLAZO_VENCIDO con teléfono).
    • El PATCH /api/citas/[id]/cancelar genérico existente se conserva para el canal email/token; el portal NO lo usa directamente (evita que el cliente suplante pacienteId).
  • Rationale: FR-005 (cero fugas entre fichas, SC-003/SC-007) y 007 FR-001 (la identidad es pacienteId verificado, nunca correo+teléfono ni campo del cliente). La renovación en cada GET implementa la caducidad deslizante de FR-003 sin endpoint extra.
  • Alternatives considered: reutilizar el PATCH genérico pasando pacienteId desde el navegador (rechazado: suplantación trivial de ficha, viola FR-005); sesión en localStorage (rechazado: prohibido por FR-003/007 FR-005).

R3. UI: Server Components + islas de cliente, móvil primero, sin importes

  • Decision:
    • Rutas: app/portal/acceso/page.tsx (formulario correo+teléfono → POST /api/portal/sesion), app/portal/mis-citas/page.tsx (Server Component que lee la sesión vía cookies() + verificarSesionPortal y renderiza las tres secciones con clasificarCitasDePortal), app/portal/page.tsx que redirige a acceso o mis-citas según sesión. Componente de cliente components/portal-cita.tsx (tarjeta + diálogo de confirmación nativo ¿Seguro que quieres cancelar esta cita? Se liberará tu hueco + botón Cancelar solo si cancelable + aviso de teléfono si no).
    • Estilo: Tailwind 4 + Boton shadcn existente (principal/peligro/secundario), tarjetas rounded-2xl, min-h-11 en botones (pulsable a 390 px sin zoom), sin desplazamiento horizontal, contraste heredado de globals.css. Textos es-ES, formato de tramo formatearTramo (08/10/2026, 10:00–10:45), estado en lenguaje de paciente. Ningún importe (FR-013: ni precio ni total).
    • «En curso» arriba con marca «en curso» y tramo resaltado, sin botón Cancelar y con aviso Ya no se puede cancelar por internet; llama a la clínica al <tel> (FR-006/FR-008). «Cerrar sesión» visible que llama a DELETE /api/portal/sesion.
  • Rationale: FR-010 (moderna, limpia, responsive 390/1440, accesible) y VII; reutilizar Boton y tokens evita alcance fantasma (IV). La confirmación explícita previa es FR-006; el Server Component evita exponer citas ajenas en el HTML.
  • Alternatives considered: SPA completa en cliente con fetch inicial (rechazado: primer render sin datos + más JS en móvil antiguo); librería de diálogos extra (rechazado: dialog nativo/confirmación simple basta, IV).

R4. Sin migración ni redefiniciones: reutilizar 005/006/007

  • Decision: cero cambios en prisma/schema.prisma (se usan Paciente, Cita con profesional/servicio, Clinica.telefono ya existente). Tiempo solo vía capturarAhora + cancelacionEnPlazo (006 FR-001/FR-005, borde 24 h incluido, minuto); normalización/sesión solo vía 007 (resolverPacientePorCredenciales, verificarSesionPortal, avisos únicos); transición solo vía ejecutarCancelarCita origen portal (005). Verificación por grep en merge (SC-004 de la 005, SC-005 de la 007, FR-010 de la 006).
  • Rationale: FR-007/FR-008/FR-016 lo exigen: esta feature no implementa su propia transición, ni su normalización, ni sus bordes. Cualquier duplicado es defecto.
  • Alternatives considered: columnas nuevas para la vista (rechazado: la vista es derivada, IV); now() local en el portal (rechazado: defecto 006 FR-010).

R5. Estrategia de tests (VI + III + V)

  • Decision: Vitest unit (tests/unit/test_portal.test.ts: partición exhaustiva/sin solapes, orden asc/desc, borde inicio == ahora → en curso, fin == ahora → anteriores, etiqueta sin importes) + integración (tests/integration/test_portal_paciente.test.ts: acceso ok/incorrecto/duplicado sin fugas, GET solo citas propias + renueva sesión, PATCH portal en plazo aplica y libera tramo reutilizable por recepción, fuera de plazo rechaza y sigue reservada, doble PATCH idempotente, sesión caducada/cerrada exige reidentificación) con semilla Eleva + ficha canónica; Playwright e2e (e2e/portal-paciente.spec.ts: acceso → ver secciones → confirmar cancelación → aparece en Anteriores, en 390 px y 1440 px, sin importes visibles, Cerrar sesión limpia).
  • Rationale: Constitución VI (cada FR/SC con test trazable), III (cero fugas y tramo libre verificados, suite antisolape 001 sigue verde), V (datos de la semilla v1, Ana García López y sesión fisio 08/10/2026 como ejemplo canónico re-datado si cambia la fecha de carga).
  • Alternatives considered: solo e2e (rechazado: bordes de minuto y concurrencia no son fiables en e2e; el contrato de clasificación es unitario).