Tasks: Recordatorios de cita (003)

Input: specs/003-recordatorios-cita/spec.md (US1/US2/US3 + FR-001…FR-017) + plan.md + research.md + data-model.md + contracts/ + quickstart.md.

Tests: incluidos porque SC-001…SC-009 exigen medición automatizada (recuentos, diffs, bordes con reloj controlado, grep de alcance).

Organización: por historia de usuario (US1 → US2 → US3) para entrega incremental; cada historia es testeable sola.

Format: [ID] [P?] [Story] Description

Phase 1: Setup (infraestructura compartida)

  • T001 Crear estructura specs/003-recordatorios-cita/contracts/ + dirs datos/salida-correo/.gitkeep y guion scripts/recordatorio-diario.ts vacío
  • T002 [P] Añadir script recordatorios en package.json (tsx scripts/recordatorio-diario.ts) y env RECORDATORIOS_HORA, APP_URL en .env.example
  • T003 [P] Ignorar datos/salida-correo/*.eml e INCIDENCIAS-* en .gitignore (mantener .gitkeep)

Phase 2: Foundational (bloqueante: BD + precio congelado)

⚠️ CRITICAL: ninguna historia empieza sin esta fase (enmienda 001 S-05 + tabla de envíos).

  • T004 Migración 003: EnvioRecordatorio (campos data-model.md + UNIQUE(citaId, huella)) + Cita.precioCongeladoCentimos INT NOT NULL DEFAULT 0 CHECK (>=0) + backfill desde Servicio.precioCentimos
  • T005 Escribir precioCongeladoCentimos en POST /api/citas (misma transacción) y en prisma/seed.ts al crear citas
  • T006 [P] ESQUEMA_EJECUTAR_RECORDATORIOS en lib/validacion.ts ({ejecucion?} ISO opcional solo tests)

Checkpoint: npx prisma migrate deploy en verde; citas nuevas traen precio congelado; backfill verificado por SQL.

Phase 3: US1 — Recuerda sin duplicar (P1) 🎯 MVP

Goal: proceso diario 24–48 h con un envío por configuración recordada (FR-001/002/003/008/015/016).

Independent Test: semilla 001 → ejecutar → 1 envío por reservada en ventana, 0 fuera/finales; re-ejecutar N veces (auto+manual) → 0 nuevos (SC-001/SC-002).

Tests US1

  • T007 [P] [US1] Unit tests/unit/test_recordatorios.test.ts: huella cambia con paciente/email/profesional/servicio/tramo; nombre .eml determinista AAAAMMDD-HHMM; asunto con clínica+fecha
  • T008 [P] [US1] Integración tests/integration/test_recordatorios_cita.test.ts: ventana (30 h sí, 5 días no, bordes 24 h/48 h sí, 23:59 no, <24 h no, finales no, día siguiente completo a las 00:00), re-ejecución sin duplicados, cita movida reenvía + histórico, servicio/email cambiados reenvían, sin-email → incidencias sin .eml, concurrente Promise.all → 1 envío

Implementation US1

  • T009 [P] [US1] lib/recordatorios.ts: huellaConfiguracion, nombreFicheroEml, componerCorreo, generarRecordatorios({ejecucion?, clinicaId?, origen}) (selección reservada + enVentanaRecordatorios + puerta 007 + create con P2002→skip + FileTransport + incidencias append-only)
  • T010 [US1] POST /api/recordatorios/ejecutar/route.ts (auth recepción, llama a generarRecordatorios origen manual)
  • T011 [US1] scripts/recordatorio-diario.ts (cron 00:00 Europe/Madrid, RECORDATORIOS_HORA configurable, origen auto) (depende de T009)

Checkpoint: US1 funciona y se prueba sola aunque el email aún no tenga página de cancelar.

Phase 4: US2 — Qué, dónde y cómo cancelar (P1)

Goal: email es-ES completo + página de confirmación que ejecuta cancelarCita (FR-004/005/006/007/009/012/017).

Independent Test: abrir .eml de la semilla (clínica, tramo, profesional, servicio, 40,00 €, teléfono, enlace, aviso no reenviar) + cancelar en plazo sin llamar y fuera de plazo con teléfono (SC-003/SC-004/SC-005).

Tests US2

  • T012 [P] [US2] Integración (mismo fichero): contenido .eml (cabeceras To/Subject/Date + cuerpo es-ES + 40,00 € + teléfono + enlace con token + aviso no reenviar + cero SMS/WhatsApp); cancelar en plazo vía token → cancelada + tramo libre RN1; visitar sin confirmar → sigue reservada; <24 h → fuera de plazo + sigue reservada; bordes 24 h justas aplica / 23:59 rechaza; doble confirmación → YA_CANCELADA; completada/no_asistida → rechazo sin mutar

Implementation US2

  • T013 [US2] app/cancelar/[id]/page.tsx (GET ver datos vigentes + POST confirmar → PATCH /api/citas/[id]/cancelar {origen:'email', token}; textos es-ES con teléfono) (depende de T009)
  • T014 [US2] npm run comprobar:es en verde para los textos nuevos + grep -ri 'sms\|whatsapp' app lib → 0 (SC-007)

Checkpoint: US1 + US2 funcionan de extremo a extremo sin llamar.

Phase 5: US3 — Simulado .eml revisable (P2)

Goal: sin SMTP → un .eml por envío, histórico intacto, incidencias en fichero (FR-010/016 → SC-006/SC-008/SC-009).

Independent Test: sin SMTP_HOST → 3 citas en ventana = 3 .eml legibles; re-ejecución → 0 nuevos; tramo movido → .eml nuevo + anterior intacto; ls | wc -l == count envíos (depende de US1/US2).

Tests US3

  • T015 [P] [US3] Integración (mismo fichero): conteo .eml == envíos tras secuencia de movimientos (SC-008); sin SMTP no hay intento de red (solo fs); cita sin email → 0 .eml + línea en INCIDENCIAS-*.txt + estado intacto (SC-009)

Implementation US3

  • T016 [US3] FileTransport final en lib/recordatorios.ts: detección por SMTP_HOST ausente (no por fallo de red), warn si presente (transporte real fuera de v1), escritura atómica (writeFileSync con flag wx → nunca sobrescribe)

Checkpoint: las tres historias funcionan de forma independiente y conjunta.

Phase 6: Polish & Cross-Cutting

  • T017 [P] Señal FR-011: GET /api/citas/[id]/recordatorio/route.ts + texto en components/tarjeta-cita.tsx («Recordatorio enviado el … a las …» / «Recordatorio pendiente», sin rutas ni hashes)
  • T018 quickstart.md validado de extremo a extremo (seed → ejecutar → .eml → cancelar → señal → incidencias)
  • T019 npm test + typecheck + lint + format:check + comprobar:es en verde; git status limpio de .eml (gitignored)

Dependencies & Execution Order

  • Setup (T001–T003) → Foundational (T004–T006, bloquea todo) → US1 tests (T007–T008 primero, deben FALLAR) → US1 impl (T009–T011) → US2 test (T012, FALLAR) → US2 impl (T013–T014) → US3 test (T015, FALLAR) → US3 impl (T016) → Polish (T017–T019).
  • Paralelo: T002+T003; T006 con T004/T005; T007+T008; T009 independiente de tests (pero sin merge hasta verlos fallar y pasar).