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.
No empezamos de cero. Este es el inventario honesto contra apps/api/src.
scheduledFor, status, retryCount, channel, type (incluye appointment_reminder) y el índice [status, scheduledFor].NotificationPreference (profile × tipo × canal × isEnabled). El consentimiento ya está modelado.WhatsAppClient.sendTemplate / sendRaw + webhook inbound con firma HMAC, dedup y BookingFlow.handleButton.EmailService con plantillas, lotes y reintentos con backoff. Ya envía confirmaciones de cita virtual.@Cron('*/5'), guard isProcessing, take N, backoff con retryCount/nextRetryAt, rate-limit. Lo copiamos tal cual.Appointment ya tiene confirmedAt, checkedInAt, AppointmentHistory y el método confirm(). Realtime a la sala ya funciona.scheduledFor, estado, messageId (wamid), reintentos. Idempotente por cita+tipo+canal.@Cron cada minuto que despacha lo vencido.BUTTON_IDS CONFIRM_<id> / RESCHEDULE_<id> → AppointmentsService.confirm()/reschedule().messages[]; falta procesar statuses[] (entregado/leído/falló) por wamid.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—.
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
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ó.
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
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.
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
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.
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 ✅"
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.
// 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 }
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.
El @@unique(appointmentId, reminderType, channel) hace imposible mandar dos veces el mismo recordatorio, aunque el cron corra solapado.
Si la API de Meta falla, retryCount++ y nextRetryAt con backoff exponencial. A los N intentos, failed + alerta.
Si la cita se cancela, reagenda o completa, las ReminderDelivery pendientes se marcan cancelled. Nunca llega un recordatorio de una cita muerta.
Antes de despachar, consulta NotificationPreference. Si el paciente apagó WhatsApp, cae al siguiente canal del channels[].
Nada de recordatorios a las 3 a. m. Si scheduledFor cae en horario silencioso, se corre al inicio de la ventana permitida.
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 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.
ReminderDelivery es la cola. El cron toma lo vencido con el índice [status, scheduledFor].SELECT … FOR UPDATE SKIP LOCKED — Postgres puro, sin extensión.WHERE status='pending' + @@unique(cita, tipo, canal).VerificationSchedulerService, ya en producción.@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.ReminderDelivery — pgmq no la reemplaza.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.@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.WhatsApp tiene reglas estrictas (plantillas aprobadas y ventana de 24 h); el correo es libre. El router lo sabe y los trata distinto.
sendTemplate() ya hace exactamente eso.quick_reply de la plantilla; su id lleva el appointmentId y vuelve por el webhook.sendRaw() — por eso reagendar muestra horarios interactivos.statuses[] en el webhook → messageId = wamid.EmailService.sendEmailWithTemplate() con un TemplateAlias de Postmark. Se crea hoy mismo.CLIENT_URL (confirmar / reagendar en la web).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.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.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.