Implementation Plan: Recordatorios de cita (003)

Branch: 003-recordatorios-cita | Date: 2026-10-01 | Spec: specs/003-recordatorios-cita/spec.md

Input: Feature specification from specs/003-recordatorios-cita/spec.md (18 clarificaciones Sara 2026-09-29/30, FR-001…FR-017, SC-001…SC-009) + MAPA.md + specs 001/005/006/007.

Note: Este plan decide stack y diseño (la constitución no los impone). Toda la UI y textos en español de España. No añade comportamiento fuera de la spec: cada decisión traza a un FR o a un hueco explícito que la spec delega al plan («ruta del informe, transporte SMTP real, plantilla, detalles del enlace, disparador manual» — Assumptions).

Summary

Proceso diario que recuerda por email cada cita reservada en ventana 24–48 h (006 FR-006) con garantía de no duplicidad por configuración recordada, email es-ES con datos + precio congelado + teléfono + enlace de cancelación con confirmación vía caso de uso único cancelarCita (005), y modo simulado .eml en datos/salida-correo/ cuando no hay SMTP. Se implementa como monolito Next.js 15 + Prisma 6 + PostgreSQL 16 existente: lib/recordatorios.ts (selección + huella + composición + FileTransport) + POST /api/recordatorios/ejecutar (disparador manual con auth de recepción) + scripts/recordatorio-diario.ts (entrada del cron 00:00 Europe/Madrid configurable) + página pública app/cancelar/[id]/page.tsx (GET ver + POST confirmar) + migración 003 (EnvioRecordatorio + Cita.precioCongeladoCentimos como enmienda menor 001 S-05 pendiente) + señal «enviado/pendiente» en la ficha.

Technical Context

Language/Version: TypeScript 5.6 strict (incl. noUncheckedIndexedAccess), Node.js 22 LTS (igual que 001/005).

Primary Dependencies: Next.js 15 (App Router Route Handlers + Server Components), React 19, Prisma 6, zod, date-fns-tz + Intl (es-ES, EUR, Europe/Madrid). Sin dependencias nuevas de correo en v1 (FileTransport con node:fs; el SMTP real queda fuera — ver Research D4).

Storage: PostgreSQL 16 (única BD). Nueva tabla EnvioRecordatorio (un row por envío, unique (citaId, huella) para no-duplicidad incluso concurrente) + enmienda menor 001 Cita.precioCongeladoCentimos INT NOT NULL (S-05: copia al reservar, backfill desde Servicio.precioCentimos). Ficheros: datos/salida-correo/recordatorio-<citaId>-AAAAMMDD-HHMM.eml (uno por envío, jamás sobrescrito) + datos/salida-correo/INCIDENCIAS-AAAA-MM-DD.txt (append-only). Migración Prisma versionada.

Testing: Vitest (unit: huella/ventana/asunto-cuerpo/formato-nombre-fichero; integración: ventana 24–48 h con bordes, no-duplicidad por re-ejecución y concurrente Promise.all, cita movida reenvía, email corregido reenvía, sin-email → incidencias sin .eml, cancelación en plazo/fuera de plazo vía enlace, idempotencia doble confirmación). Sin e2e Playwright nuevo (el contrato es de proceso + email + página simple; Playwright existente cubre responsive del panel; la página de cancelar se verifica con tests de Route Handler + render manual del quickstart).

Target Platform: Web responsive + proceso batch (contenedor Node 22 + Postgres 16 existentes). Cron del sistema / scheduler externo invoca npm run recordatorios (script tsx) a las 00:00 Europe/Madrid; el disparador manual es el Route Handler con la misma función.

Project Type: web-application monolítica (igual que 001/005; sin backend//frontend/ separados).

Performance Goals: Proceso diario sobre ~2 semanas de reservas (<2000 citas) p95 <5 s en local (objetivo no bloqueante, sin tarea de medición dedicada); doble ejecución (auto + manual) concurrente resuelve con cero duplicados (SC-002); 100 % .eml con contenido mínimo (SC-003).

Constraints: Español de España en email, avisos y errores (VIII + FR-012, verificado con comprobar:es); fechas Europe/Madrid sin ambigüedad + importes 40,00 € desde céntimos (II, 001 FR-017/FR-018); tiempo solo vía 006 (capturarAhora, enVentanaRecordatorios, cancelacionEnPlazo, minuto truncado; prohibido redefinir — 006 FR-010); cancelación solo vía ejecutarCancelarCita origen email (005 FR-001; esta feature MUST NOT implementar transición propia); identidad/email solo vía 007 (normalizarCorreo, decidirEnvioRecordatorio; prohibido definir normalización propia); Cita.estado y tramos propiedad de la 001 (FR-014); cero SMS/WhatsApp en código, textos o dependencias (FR-013, SC-007); sin recuperación tardía y sin recordar <24 h (FR-001/Assumptions, huecos conscientes).

Scale/Scope: 1–N clínicas; ~40 pacientes y ~500–120o citas como en la 001; un envío por cita y configuración (cientos de .eml por ejecución como techo); peor caso decenas de ejecuciones concurrentes sobre la misma cita.

Constitution Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

  • I. Spec First: todo comportamiento (FR-001…FR-017 + Q1…Q18) vive en spec.md; el plan solo decide lo delegado (ruta informe, plantilla texto plano, enlace verificado reutilizado, disparador manual). Propietario comunicación con el paciente en specs/MAPA.md; la enmienda a Cita (precioCongeladoCentimos) se acuerda con el propietario 001 por enmienda S-05 ya aceptada jul2026.
  • II. Exactitud numérica y temporal: céntimos enteros + formatearEuros (40,00 €) desde precioCongeladoCentimos (no desde tarifa vigente); timestamptz + Europe/Madrid + bordes 006 con ejemplos límite 24 h/48 h justos dentro, 23:59 fuera.
  • III. Cero solapes: ningún envío crea, mueve ni solapa citas (FR-014); cancelar libera tramo vía 005 y se verifica reutilización RN1 sin solapes.
  • IV. Simplicidad / cero alcance fantasma: una tabla nueva (EnvioRecordatorio) + una columna de enmienda (precioCongeladoCentimos); sin colas/Redis/cron interno/SMTP real/auth nueva/i18n; cada tabla/campo/endpoint traza a un FR (ver justificaciones abajo).
  • V. Datos reproducibles: semilla Eleva existente; escenarios de quickstart.md citables por fecha/hora relativa (diaDePrueba); regeneración bit a bit intacta.
  • VI. Tests con la spec: matriz test↔FR/SC en quickstart.md; suite + concurrente en verde como puerta de merge.
  • VII. Interfaz clara y moderna: señal en ficha («Recordatorio enviado el … a las …» / «Recordatorio pendiente», sin rutas ni hashes); email y página de cancelar en lenguaje de clínica, responsive heredado.
  • VIII. Español de España: email/asunto/avisos/incidencias en es-ES con teléfono de la clínica; verificación con comprobar:es + grep SMS/WhatsApp.

Gates: PASS — sin violaciones que justificar. Complexity Tracking queda vacío.

Justificaciones IV (adiciones trazadas, no alcance fantasma): (1) EnvioRecordatorio implementa FR-003/FR-008/FR-010 (registro que detecta re-ejecución + histórico por tramo + 1:1 con .eml); (2) Cita.precioCongeladoCentimos aplica la enmienda S-05 (001 FR-006) que la spec presupone como fuente del precio del email (FR-004); (3) POST /api/recordatorios/ejecutar implementa el disparador manual FR-015; (4) GET /api/citas/[id]/recordatorio implementa la señal FR-011 sin pantalla nueva de envíos (Q11); (5) app/cancelar/[id]/ implementa FR-006/FR-007/FR-009 como fachada fina sobre cancelarCita (sin transición propia).

Project Structure

Documentation (this feature)

specs/003-recordatorios-cita/
├── plan.md              # This file (/speckit.plan command output)
├── research.md          # Phase 0 output (/speckit.plan command)
├── data-model.md        # Phase 1 output (/speckit.plan command)
├── quickstart.md        # Phase 1 output (/speckit.plan command)
├── contracts/           # Phase 1 output (/speckit.plan command)
│   ├── recordatorios-api.md  # POST ejecutar + GET señal + huellas
│   ├── cancelar-email.md     # página cancelar + PATCH reuse 005/007
│   └── eml-formato.md        # asunto/cuerpo/nombre .eml + incidencias
└── tasks.md             # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)

Source Code (repository root)

tema5/citaclara/
├── app/
│   ├── api/
│   │   ├── recordatorios/ejecutar/route.ts  # POST manual (auth recepción, FR-015)
│   │   └── citas/[id]/recordatorio/route.ts # GET señal enviada/pendiente (FR-011)
│   └── cancelar/[id]/page.tsx               # GET ver + POST confirmar vía 005 (FR-006/007/009)
├── lib/
│   ├── recordatorios.ts      # selección ventana + huella + compose + FileTransport (FR-001…004/008/010/015/016)
│   ├── ventanas.ts           # reutilizado 006 (enVentanaRecordatorios/cancelacionEnPlazo/capturarAhora)
│   ├── cancelacion.ts        # reutilizado 005 (ejecutarCancelarCita, sin cambios salvo tipos)
│   ├── identidad.ts          # reutilizado 007 (normalizarCorreo/decidirEnvioRecordatorio)
│   ├── enlace-verificado.ts  # reutilizado 007 FR-006 (token del email, sin cambios)
│   ├── dinero.ts / tiempo.ts # reutilizados (40,00 €, DD/MM/AAAA HH:MM)
│   └── validacion.ts         # + ESQUEMA_EJECUTAR_RECORDATORIOS (solo ejecucion opcional)
├── prisma/
│   ├── schema.prisma         # + EnvioRecordatorio + Cita.precioCongeladoCentimos
│   ├── migrations/XXXX_003_recordatorios_cita/migration.sql
│   └── seed.ts               # + precioCongeladoCentimos al crear citas (backfill coherente)
├── scripts/
│   └── recordatorio-diario.ts # entrada cron 00:00 Europe/Madrid (FR-015) → generaRecordatorios()
├── datos/salida-correo/      # .eml + INCIDENCIAS-*.txt (gitignored, FR-010/FR-016)
├── tests/
│   ├── unit/test_recordatorios.test.ts
│   └── integration/test_recordatorios_cita.test.ts
└── components/tarjeta-cita.tsx # + señal recordatorio (FR-011, sin rutas ni hashes)

Structure Decision: Monolito Next.js existente (misma estructura que 001/005). Sin split backend/frontend: la lógica vive en lib/recordatorios.ts y las rutas son fachadas finas. Prisma es la única capa de datos (sin Repository extra — simplicidad IV). El cron no vive en la app: el script es invocable por systemd/docker/crontab con TZ=Europe/Madrid y hora configurable RECORDATORIOS_HORA=00:00.