Research — 003 Recordatorios de cita

Fuente: spec.md (FR-001…FR-017, Q1…Q18). Cada decisión cita el FR que la exige. Reutilizar antes que inventar: 005/006/007 ya están implementados en lib/.

D1. Ventana 24–48 h y «ahora» único (FR-001, 006 FR-001/FR-002/FR-006)

Decisión: usar capturarAhora(ejecucion?) una vez por ejecución y enVentanaRecordatorios(inicio, ejecucion) de lib/ventanas.ts sin copiar la fórmula. Ambas truncan a minuto; bordes incluidos (24 h ≤ Δ ≤ 48 h). El horario por defecto es 00:00 Europe/Madrid (Q7) porque a esa hora la ventana cubre exactamente el día siguiente completo (00:30 → 24,5 h; 23:00 → 47 h; edge spec).

Alternativas rechazadas: calcular Δ en SQL con now() (viola 006 FR-001: la BD nunca usa su propio now() para reglas); reimplementar bordes en recordatorios.ts (viola 006 FR-010: defecto por definición duplicada).

D2. No-duplicidad por configuración recordada + concurrencia (FR-003, Q4/Q6/Q16)

Decisión: huella sha256 de la configuración recordada = pacienteId | emailNormalizado | profesionalId | servicioId | inicioISO | finISO (Q6 + email como destinatario). Tabla EnvioRecordatorio(citaId, huella) con UNIQUE(citaId, huella); la ejecución corre en transacción serializable por cita y captura P2002 (unique) como «ya enviado, skip». Re-ejecución sin cambios → cero envíos nuevos. Cita movida / servicio cambiado / email corregido → huella distinta → nuevo envío si el nuevo tramo cae en ventana; el anterior queda como histórico (vigente=false implícito: el vigente es el de mayor creadoEn; nunca se borra ni se sobrescribe — Q4/Q5).

Alternativas rechazadas: flag recordada:true en Cita (no distingue tramos: impediría el reenvío exigido por FR-008); lock en memoria (no sobrevive a dos procesos del cron + manual simultáneos).

D3. Destinatario, normalización y duplicados (FR-004, 007 FR-002/FR-004)

Decisión: destinatario = Paciente.email de la ficha; antes de enviar, normalizarCorreo(email) + ESQUEMA_EMAIL (validación estricta 001 FR-004). Comparación de «email corregido» (Q16/edge) usa la normalización compartida de lib/identidad.ts (prohibido definir otra). Puerta de envío decidirEnvioRecordatorio({correo, telefono}): incorrecto/duplicado → sin .eml, anotado en incidencias con motivo, sin reintento (FR-016, 007 FR-004).

Alternativas rechazadas: enviar al email sin normalizar (dos variantes del mismo email generarían huellas distintas y duplicados); tratar duplicado como envío al primero (viola 007 FR-004: cero fugas entre fichas).

D4. Modo simulado .eml y SMTP (FR-010, SC-006)

Decisión: FileTransport: si SMTP_HOST está ausente → escribir .eml (texto plano RFC822 mínimo: To/Subject/Date + Content-Type: text/plain; charset=utf-8 + cuerpo es-ES). Si SMTP_HOST está presente en v1 → se sigue escribiendo .eml y se registra warn («SMTP configurado pero transporte real fuera de alcance v1»): FR-010 solo exige el comportamiento sin SMTP; el envío real va en spec posterior sin cambiar el observable de esta spec. Detección por configuración ausente, nunca por fallo de red enmascarado.

Alternativas rechazadas: dependencia nodemailer en v1 (alcance fantasma: la spec delega el transporte real al plan posterior y SC-006 exige cero envíos de red en el piloto).

D5. Nombre .eml determinista e histórico (FR-010, Q5)

Decisión: recordatorio-<citaId>-AAAAMMDD-HHMM.eml donde AAAAMMDD-HHMM es el inicio del tramo en Europe/Madrid (fechaLocalEnMadrid + horaLocalEnMadrid con :→``). Un fichero por envío; el sistema nunca sobrescribe ni borra (check existsSync + sufijo solo si colisión imposible por unicidad de tramo; SC-008: nº ficheros == nº envíos).

Alternativas rechazadas: nombre solo por citaId (sobrescribiría el histórico, viola Q5); UUID aleatorio (no determinista, impide el diff de SC-002).

D6. Contenido del email (FR-004/FR-005/FR-012, Q13/Q15)

Decisión: texto plano es-ES con: clínica, tramo formatearTramo («30/09/2026, 10:00–10:45»), profesional, servicio, precio formatearEuros(precioCongeladoCentimos) («40,00 €»), teléfono Clinica.telefono, enlace POST de cancelación con token, aviso «no reenvíes este enlace», y nota de fuera de plazo («si faltan menos de 24 h, llama al …»). Asunto: «Recordatorio: tu cita en el {DD/MM/AAAA} a las ». Cero menciones a SMS/WhatsApp (FR-013; grep en CI).

Alternativas rechazadas: HTML multipart (la spec delega la plantilla al plan; texto plano es revisable con editor y basta para SC-003); precio desde tarifa vigente (viola FR-004: el precio es el congelado en la cita, 001 FR-006/S-05).

D7. Enlace y página de cancelación (FR-006/FR-007/FR-009/FR-017, Q2/Q3/Q8/Q9)

Decisión: reutilizar crearEnlaceVerificado({citaId, pacienteId, inicioCita}) (007 FR-006: token atado a cita+paciente, caduca en inicio o 72 h, antiflood 10/h) y ejecutarCancelarCita({origen:'email'}) (005). La página app/cancelar/[id]/ con ?token= muestra datos vigentes (GET nunca escribe — 005 FR-003) y un botón «Confirmar cancelación» que hace PATCH /api/citas/[id]/cancelar {origen:'email', token}. Plazo y estados los decide cancelarCita (borde 24 h incluido, sin tope superior — Q8; reservada solo — FR-009; idempotente YA_CANCELADA). Fuera de plazo → «fuera de plazo, llama a la clínica al » (Q3/Q15). La «deuda consciente» Q9/FR-017 queda cerrada por construcción (token verificado desde el día uno) manteniendo el aviso de no reenviar.

Alternativas rechazadas: enlace desnudo ?citaId= sin token que autocancelara por GET (viola 005 FR-003 y reintroduce la deuda que 007 ya cierra); implementar transición propia en recordatorios (viola 005 FR-001/SC-004).

D8. Precio congelado ausente en BD (FR-004 ← 001 FR-006/S-05)

Decisión: enmienda menor 001: Cita.precioCongeladoCentimos INT NOT NULL con backfill (UPDATE Cita ← Servicio.precioCentimos por servicioId) y escritura en POST /api/citas (precioCongeladoCentimos = servicio.precioCentimos en la misma transacción). Las citas ya existentes quedan coherentes; las nuevas lo copian al reservar y nunca cambian.

Alternativas rechazadas: leer el precio del servicio al componer el email (divergiría si la tarifa cambia después; viola FR-004 + edge «cambio de servicio»).

D9. Ejecución automática + manual (FR-015, Q7/Q14)

Decisión: función única generarRecordatorios({ejecucion?, clinicaId?}) invocada por (a) scripts/recordatorio-diario.ts (TZ=Europe/Madrid, hora RECORDATORIOS_HORA=00:00 configurable, ejecutado por cron/docker) y (b) POST /api/recordatorios/ejecutar (auth recepción vía exigirClinica, mismo comportamiento + misma no-duplicidad). Ambos capturan ejecucion = capturarAhora() una vez.

Alternativas rechazadas: cron interno con node-cron (nueva dependencia + doble scheduler en réplicas); solo manual (viola FR-015: el automático es obligatorio).

D10. Informe de incidencias (FR-016, Q12)

Decisión: fichero append-only datos/salida-correo/INCIDENCIAS-AAAA-MM-DD.txt (mismo directorio que los .eml para que recepción revise un solo sitio), línea por cita descartada: ISO-ejecucion | citaId | motivo (sin-email | email-invalido | duplicado). Sin reintento posterior (Q12/Q10), sin tocar agenda.

Alternativas rechazadas: tabla BD de incidencias (la spec pide «fichero revisable»; tabla exigiría UI nueva, prohibida por Q11); log solo en consola (no revisable por recepción).