Amedi recordatorios · arquitectura técnica
← volver al diseño del flujo
cómo se genera y se envía un recordatorio

La máquina detrás
del recordatorio.

Todo lo de aquí está anclado en el código real de apps/api. La buena noticia: media máquina ya existe. El modelo Notification ya es programable, el cliente de WhatsApp Cloud API y Postmark ya envían, y VerificationSchedulerService ya nos da el patrón exacto de cron + cola en base de datos. Lo que falta es el motor que orquesta la línea de tiempo y el mapeo de confirmar/reagendar.

punto de partida · el código real
Lo que ya hay y lo que falta.

No empezamos de cero. Este es el inventario honesto contra apps/api/src.

ya existese reutiliza
Notification programable
Ya tiene scheduledFor, status, retryCount, channel, type (incluye appointment_reminder) y el índice [status, scheduledFor].
prisma/schema.prisma · Notification
Opt-out por canal
NotificationPreference (profile × tipo × canal × isEnabled). El consentimiento ya está modelado.
schema.prisma · NotificationPreference
WhatsApp Cloud API
WhatsAppClient.sendTemplate / sendRaw + webhook inbound con firma HMAC, dedup y BookingFlow.handleButton.
src/whatsapp/* · webhook/*
Email · Postmark
EmailService con plantillas, lotes y reintentos con backoff. Ya envía confirmaciones de cita virtual.
src/email/email.service.ts
Patrón de cron + cola
@Cron('*/5'), guard isProcessing, take N, backoff con retryCount/nextRetryAt, rate-limit. Lo copiamos tal cual.
verification/verification-scheduler.service.ts
Ciclo de vida de la cita
Appointment ya tiene confirmedAt, checkedInAt, AppointmentHistory y el método confirm(). Realtime a la sala ya funciona.
appointments/appointments.service.ts
falta construirnuevo
ReminderRule
La config por doctor/especialidad: qué tipos, qué ventanas, qué canales y el texto editable.
nuevo modelo Prisma
ReminderDelivery (la cola)
Cada envío programado: canal, scheduledFor, estado, messageId (wamid), reintentos. Idempotente por cita+tipo+canal.
nuevo modelo Prisma
RemindersScheduler + Dispatcher
Materializa la línea de tiempo al crear/confirmar la cita, y un @Cron cada minuto que despacha lo vencido.
nuevo · src/reminders/
Plantillas de WhatsApp aprobadas
recordatorio 24h, preparación, llegada, seguimiento, reagendar. Hoy hay 2 plantillas; faltan estas.
whatsapp.constants.ts + Meta Manager
Confirmar / reagendar en handleButton
Nuevos BUTTON_IDS CONFIRM_<id> / RESCHEDULE_<id>AppointmentsService.confirm()/reschedule().
whatsapp/flows/booking-flow.service.ts
Recibos de entrega
El webhook hoy solo lee messages[]; falta procesar statuses[] (entregado/leído/falló) por wamid.
webhook/whatsapp-webhook.service.ts
arquitectura del sistema
Una sola tubería, dos direcciones.

Salida: los eventos de la cita siembran la cola, un cron la vacía hacia WhatsApp/correo. Entrada: lo que el paciente toca vuelve por el webhook y mueve el estado de la cita —que se pinta en la sala de espera en tiempo real—.

arquitectura · salida (recordatorios) + entrada (confirmar/reagendar)
flowchart TB
  subgraph EV["eventos de cita · appointments.service"]
    direction LR
    A1["cita creada"]
    A2["cita confirmada"]
    A3["cita reagendada"]
    A4["cancelada / completada"]
  end
  SCH["RemindersScheduler
calcula la línea de tiempo
según ReminderRule del doctor"] Q[("ReminderDelivery
cola en DB · pending · scheduledFor")] CRON{{"RemindersDispatcher
@Cron cada minuto"}} PREF{"opt-out · quiet hours
NotificationPreference"} ROU["channel router
+ fallback"] WA["WhatsAppService
sendTemplate"] EM["EmailService
Postmark"] PU["NotificationsService
push · in-app"] META[("Meta Cloud API")] PM[("Postmark")] HOOK["/webhook inbound
HMAC + dedup/"] FLOW["BookingFlow.handleButton"] APT["AppointmentsService
confirm / reschedule"] DB[("appointments + history")] SALA(["sala de espera · consultorio
realtime"]) PAT(["paciente"]) A1 --> SCH A2 --> SCH A3 --> SCH A4 -. "cancela pendientes" .-> Q SCH -- "materializa N filas" --> Q CRON -- "toma vencidas (take N)" --> Q CRON --> PREF PREF -- "ok" --> ROU ROU --> WA --> META --> PAT ROU --> EM --> PM --> PAT ROU --> PU META -. "statuses: delivered/read/failed" .-> HOOK PAT -- "toca Confirmar / Reagendar" --> META HOOK --> FLOW --> APT --> DB APT -- "realtime" --> SALA HOOK -. "actualiza por wamid" .-> Q
diagrama de secuencia · 1 de 3
Cómo se genera y se envía un recordatorio.

Del momento en que se agenda la cita hasta el "entregado" de WhatsApp. La línea de tiempo se materializa una vez; el cron solo despacha lo que ya venció.

secuencia · generación → cola → despacho → entrega
sequenceDiagram
  autonumber
  actor Doc as Doctor / secretaria
  participant API as AppointmentsService
  participant SCH as RemindersScheduler
  participant Q as ReminderDelivery (DB)
  participant CR as RemindersDispatcher @Cron
  participant WA as WhatsAppService
  participant M as Meta Cloud API
  actor P as Paciente

  Doc->>API: crea / confirma la cita
  API->>SCH: onAppointmentScheduled(appointment)
  SCH->>SCH: lee ReminderRule (ventanas + canales)
  SCH->>Q: inserta N deliveries
pending · scheduledFor = cita − ventana Note over CR: cada minuto CR->>Q: SELECT pending WHERE scheduledFor ≤ now (take N) CR->>CR: opt-out? · quiet hours? · dedup? CR->>WA: sendTemplate(to, appointment_reminder_24h, vars) WA->>M: POST /messages (template) M-->>WA: wamid CR->>Q: status = sent · messageId = wamid M-->>P: recordatorio con botones M--)CR: webhook statuses (delivered / read) CR->>Q: status = delivered
diagrama de secuencia · 2 de 3
El paciente confirma.

Un toque en "Confirmar asistencia" entra por el mismo webhook que ya existe, reusa AppointmentsService.confirm() y se pinta en la sala de espera del doctor en tiempo real.

secuencia · confirmar asistencia (inbound)
sequenceDiagram
  autonumber
  actor P as Paciente
  participant M as Meta Cloud API
  participant HK as WhatsAppWebhook
  participant FL as BookingFlow.handleButton
  participant AP as AppointmentsService
  participant DB as appointments
  participant RT as Realtime
  actor Sec as Consultorio (sala)

  P->>M: toca "Confirmar asistencia"
  M->>HK: POST webhook
button_reply.id = CONFIRM_<apptId> HK->>HK: verifica firma HMAC + dedup (wamid) HK->>FL: handleButton(from, CONFIRM_<apptId>) FL->>AP: confirm(apptId, by = system) AP->>DB: status = confirmed · confirmedAt = now AP->>DB: AppointmentHistory (scheduled → confirmed) AP->>RT: publish appointment.updated RT-->>Sec: la cita se pinta "confirmada" FL-->>M: "¡Listo! tu cita quedó confirmada ✅" M-->>P: confirmación
diagrama de secuencia · 3 de 3
El paciente reagenda.

La ventana de 24 h está abierta (el paciente acaba de escribir), así que podemos mandar una lista interactiva con los huecos reales del doctor. Al reagendar, las notificaciones viejas se cancelan y se siembra una línea de tiempo nueva.

secuencia · reagendar con huecos reales
sequenceDiagram
  autonumber
  actor P as Paciente
  participant HK as WhatsAppWebhook
  participant FL as BookingFlow
  participant AV as AvailabilityService
  participant AP as AppointmentsService
  participant SCH as RemindersScheduler
  participant Q as ReminderDelivery

  P->>HK: toca "Reagendar" (RESCHEDULE_<apptId>)
  HK->>FL: handleButton
  FL->>AV: huecos libres del doctor
(Availability − excepciones − citas) AV-->>FL: [vie 15 · 11:30, lun 18 · 9:00, …] FL-->>P: lista interactiva de horarios P->>HK: elige "vie 15 · 11:30" HK->>FL: handleButton(SLOT_<id>) FL->>AP: reschedule(apptId, nuevo slot) AP->>Q: cancela deliveries pendientes (cita vieja) AP->>SCH: onAppointmentRescheduled → nueva línea de tiempo AP-->>FL: ok FL-->>P: "¡Reagendada! viernes 15 · 11:30 ✅"
modelo de datos · lo nuevo
Dos tablas nuevas. Cero enums nuevos de canal.

ReminderRule guarda la config del doctor; ReminderDelivery es la cola de envíos —idéntica en espíritu a DoctorVerification—. Ambas reutilizan los enums NotificationChannel y NotificationStatus que ya existen.

apps/api/prisma/schema.prisma · (nuevo)
// config del doctor: qué se manda, cuándo y por dónde
model ReminderRule {
  ruleId        String   @id @default(uuid())
  doctorId      String
  specialtyId   String?                  // null = todas sus especialidades
  reminderType  ReminderType
  isEnabled     Boolean  @default(true)
  offsetMinutes Int                      // −1440 = 24h antes · +120 = 2h después
  channels      NotificationChannel[]    // orden de prioridad / fallback
  bodyTemplate  String?  @db.Text         // texto editable: "Hola {nombre}…"

  doctor    Doctor     @relation(fields: [doctorId], references: [doctorId])
  specialty Specialty? @relation(fields: [specialtyId], references: [specialtyId])

  @@unique([doctorId, specialtyId, reminderType, offsetMinutes])
  @@index([doctorId])
}

// la cola: un envío programado y despachable (espejo de DoctorVerification)
model ReminderDelivery {
  deliveryId    String             @id @default(uuid())
  appointmentId String
  ruleId        String?
  reminderType  ReminderType
  channel       NotificationChannel             // reusa enum existente
  scheduledFor  DateTime
  status        NotificationStatus @default(pending)  // pending|sent|delivered|failed
  messageId     String?                        // wamid o Postmark MessageID
  retryCount    Int                @default(0)
  nextRetryAt   DateTime?
  errorMessage  String?            @db.Text
  sentAt        DateTime?
  payload       Json?                          // variables de la plantilla congeladas

  appointment   Appointment @relation(fields: [appointmentId], references: [appointmentId], onDelete: Cascade)

  @@unique([appointmentId, reminderType, channel])   // idempotencia
  @@index([status, scheduledFor])                     // el cron filtra por aquí
}

enum ReminderType { cita_proxima  preparacion  llegada  seguimiento  control }
el motor de envío
Cron + cola en DB. Nada de colas externas.

Mismo patrón que VerificationSchedulerService: un @Cron que cada minuto toma lo vencido, lo despacha por canal y reintenta con backoff. Sin Redis, sin BullMQ — la base de datos es la cola.

Idempotencia

El @@unique(appointmentId, reminderType, channel) hace imposible mandar dos veces el mismo recordatorio, aunque el cron corra solapado.

Reintentos con backoff

Si la API de Meta falla, retryCount++ y nextRetryAt con backoff exponencial. A los N intentos, failed + alerta.

Cancelación viva

Si la cita se cancela, reagenda o completa, las ReminderDelivery pendientes se marcan cancelled. Nunca llega un recordatorio de una cita muerta.

Respeta el opt-out

Antes de despachar, consulta NotificationPreference. Si el paciente apagó WhatsApp, cae al siguiente canal del channels[].

Quiet hours

Nada de recordatorios a las 3 a. m. Si scheduledFor cae en horario silencioso, se corre al inicio de la ventana permitida.

Zona horaria

Todo se calcula en America/Caracas. Las ventanas (−24 h, −2 h) se anclan al date + startTime local de la cita, no a UTC crudo.

la pregunta de la cola
¿Hace falta una cola? Todavía no.

La DB del API ya es Supabase Postgres, así que pgmq (Supabase Queues) está disponible. Pero los recordatorios son trabajo agendado por tiempo, no de alto volumen — y la tabla ReminderDelivery ya te da semántica de cola: scheduledFor dice cuándo, status dice qué falta. Esa tabla hace falta igual (wamid, recibos, estado en la sala, idempotencia, auditoría). Meter pgmq encima sería dos fuentes de verdad para lo mismo.

tabla + @Cron
recomendado · v1
  • La tabla ReminderDelivery es la cola. El cron toma lo vencido con el índice [status, scheduledFor].
  • Concurrencia segura entre instancias con SELECT … FOR UPDATE SKIP LOCKED — Postgres puro, sin extensión.
  • Doble-envío imposible: UPDATE condicional WHERE status='pending' + @@unique(cita, tipo, canal).
  • Cero infra nueva. Mismo patrón que VerificationSchedulerService, ya en producción.
pg_cron · pgmq
el upgrade · cuándo sí
  • El tick: el día que haya >1 réplica del API, el @Cron in-app dispara N veces. Ahí pg_cron (corre en la DB, una sola vez) toma el relevo, vía pg_net pegándole al endpoint de despacho.
  • pg_cron es fiable: vive en Postgres —tu componente más disponible, sobrevive a los deploys— y registra cada corrida en cron.job_run_details.
  • La cola (pgmq) solo gana si el despacho sale fuera de Nest (Edge Functions) o el volumen explota. Aun así seguirías necesitando ReminderDelivery — pgmq no la reemplaza.
  • En tu proyecto: pg_cron y pg_net están disponibles (sin instalar); pgmq está instalado pero sin uso (0 colas, sin código que lo toque). Puerta abierta sin costo de setup, sin convención previa que respetar.
Veredicto · 1 contenedor de API (hoy)
La tabla es la cola y el tick es @Cron in-app — sin double-fire con una sola réplica, y el catch-by-poll hace inofensivo un tick perdido en un deploy. Higiene barata: FOR UPDATE SKIP LOCKED en el claim + dejar el tick como método intercambiable. El día de la 2ª réplica, se cambia a pg_cron sin tocar dispatcher, router ni canales. pgmq queda para cuando el despacho salga del API o el volumen lo exija.
los dos canales · cómo difieren
WhatsApp y correo no juegan igual.

WhatsApp tiene reglas estrictas (plantillas aprobadas y ventana de 24 h); el correo es libre. El router lo sabe y los trata distinto.

Correo
respaldo · Postmark
  • Sin ventana, sin aprobación previa. EmailService.sendEmailWithTemplate() con un TemplateAlias de Postmark. Se crea hoy mismo.
  • Entra como fallback: si el paciente no tiene WhatsApp o lo apagó, el router lo usa.
  • Lotes con reintentos y backoff ya implementados en el servicio.
  • Sin botones nativos: los CTA van como enlaces a CLIENT_URL (confirmar / reagendar en la web).
El bloqueo real: plantillas de WhatsApp
Crear plantillas nuevas en Meta Business Manager está bloqueado hasta completar la verificación de negocio de la WABA de Amedi. El diseño avanza igual, pero el go-live por WhatsApp depende de eso. Mientras tanto, el piloto puede arrancar por correo + push/in-app, que no tienen ese candado, y prender WhatsApp en cuanto las plantillas estén aprobadas.
dónde se engancha en el código
Tres puntos de sutura, nada invasivo.
1 · al crear/confirmar/reagendar la cita
appointments.service.ts ya tiene un helper fire-and-forget (notifyAppointmentEvent). Se agrega un remindersScheduler.sync(appointment) con el mismo estilo no-bloqueante. Cero cambios al flujo de agenda.
2 · al recibir un toque del paciente
booking-flow.service.ts → handleButton() ya enruta button_reply.id. Se añaden los casos CONFIRM_*, RESCHEDULE_* y SLOT_*. El webhook, la firma y el dedup ya están.
3 · módulo nuevo RemindersModule
src/reminders/ con reminders.scheduler.ts (siembra), reminders.dispatcher.ts (@Cron), reminders.service.ts (config CRUD para el panel del doctor) y DTOs. Importa WhatsAppModule, EmailModule, NotificationsModule — todos ya existen.