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)enlib/portal.ts, sin BD ni estado:enCurso=estado === 'reservada' && inicio <= ahora < fin(truncado a minuto, primitivas 006).proximas=estado === 'reservada' && inicio > ahora, orden asc porinicio.anteriores= todo lo demás (completada/cancelada/no_asistida+reservadaconfin <= ahora), orden desc porinicio.- 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
reservadade 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 delib/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/citaslee la cookiecitaclara_portal, la verifica converificarSesionPortal(007), renueva conrenovarSesionPortal(deslizante 30 min) y devuelve{ enCurso, proximas, anteriores, telefonoClinica }filtrandoWHERE pacienteId = sesion.pacienteId. Sin sesión válida →401con reidentificación (mismo aviso de sesión caducada, sin revelar datos).PATCH /api/portal/citas/[id]/cancelar(fachada fina nueva) verifica la sesión, derivapacienteIdVerificadodel servidor y llama aejecutarCancelarCita({ pacienteIdVerificado, citaId, origen: 'portal' })(005). El cuerpo solo trae{ confirmar: true }; se ignora cualquierpacienteIddel cliente. Rechazos con el catálogo único de la 005 (incluidoPLAZO_VENCIDOcon teléfono).- El
PATCH /api/citas/[id]/cancelargenérico existente se conserva para el canal email/token; el portal NO lo usa directamente (evita que el cliente suplantepacienteId).
- Rationale: FR-005 (cero fugas entre fichas, SC-003/SC-007) y 007 FR-001 (la identidad es
pacienteIdverificado, nunca correo+teléfono ni campo del cliente). La renovación en cadaGETimplementa la caducidad deslizante de FR-003 sin endpoint extra. - Alternatives considered: reutilizar el
PATCHgenérico pasandopacienteIddesde el navegador (rechazado: suplantación trivial de ficha, viola FR-005); sesión enlocalStorage(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íacookies()+verificarSesionPortaly renderiza las tres secciones conclasificarCitasDePortal),app/portal/page.tsxque redirige aaccesoomis-citassegún sesión. Componente de clientecomponents/portal-cita.tsx(tarjeta + diálogo de confirmación nativo¿Seguro que quieres cancelar esta cita? Se liberará tu hueco+ botón Cancelar solo sicancelable+ aviso de teléfono si no). - Estilo: Tailwind 4 +
Botonshadcn existente (principal/peligro/secundario), tarjetasrounded-2xl,min-h-11en botones (pulsable a 390 px sin zoom), sin desplazamiento horizontal, contraste heredado deglobals.css. Textos es-ES, formato de tramoformatearTramo(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 aDELETE /api/portal/sesion.
- Rutas:
- Rationale: FR-010 (moderna, limpia, responsive 390/1440, accesible) y VII; reutilizar
Botony 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
fetchinicial (rechazado: primer render sin datos + más JS en móvil antiguo); librería de diálogos extra (rechazado:dialognativo/confirmación simple basta, IV).
R4. Sin migración ni redefiniciones: reutilizar 005/006/007
- Decision: cero cambios en
prisma/schema.prisma(se usanPaciente,Citaconprofesional/servicio,Clinica.telefonoya existente). Tiempo solo víacapturarAhora+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íaejecutarCancelarCitaorigenportal(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, bordeinicio == ahora→ en curso,fin == ahora→ anteriores, etiqueta sin importes) + integración (tests/integration/test_portal_paciente.test.ts: acceso ok/incorrecto/duplicado sin fugas,GETsolo citas propias + renueva sesión,PATCHportal en plazo aplica y libera tramo reutilizable por recepción, fuera de plazo rechaza y siguereservada, doblePATCHidempotente, 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ónlimpia). - 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 fisio08/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).