@voyantjs/notifications 0.28.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,61 @@
1
1
  import { bookings, bookingTravelers } from "@voyantjs/bookings/schema";
2
2
  import { bookingPaymentSchedules, invoices, paymentSessions } from "@voyantjs/finance";
3
3
  import { and, asc, desc, eq, gt, or } from "drizzle-orm";
4
- import { notificationReminderRules, notificationReminderRuns } from "./schema.js";
4
+ import { notificationReminderRules, notificationReminderRuns, notificationReminderStageChannels, } from "./schema.js";
5
5
  import { bookingDocumentNotificationsService, createDefaultBookingDocumentAttachment, } from "./service-booking-documents.js";
6
6
  import { sendInvoiceNotification, sendNotification } from "./service-deliveries.js";
7
- import { addUtcDays, buildReminderDedupeKey, listBookingNotificationItems, resolveReminderRecipient, startOfUtcDay, toDateString, toTimestamp, } from "./service-shared.js";
7
+ import { applyQuietHours, evaluateStage, exceedsRecipientRateLimit, fetchTargetsForRule, getNotificationSettings, listActiveRulesByPriority, listChannelsForStage, listStagesForRule, loadHistory, suppressedByGroup, } from "./service-sequence.js";
8
+ import { buildReminderDedupeKey, listBookingNotificationItems, resolveReminderRecipient, startOfUtcDay, toDateString, toTimestamp, } from "./service-shared.js";
8
9
  async function getBookingPaymentNotificationContext(db, bookingId) {
9
10
  const [[paymentSchedule], [invoice], [paymentSession]] = await Promise.all([
10
11
  db
11
- .select()
12
+ .select({
13
+ id: bookingPaymentSchedules.id,
14
+ bookingId: bookingPaymentSchedules.bookingId,
15
+ scheduleType: bookingPaymentSchedules.scheduleType,
16
+ status: bookingPaymentSchedules.status,
17
+ dueDate: bookingPaymentSchedules.dueDate,
18
+ currency: bookingPaymentSchedules.currency,
19
+ amountCents: bookingPaymentSchedules.amountCents,
20
+ createdAt: bookingPaymentSchedules.createdAt,
21
+ })
12
22
  .from(bookingPaymentSchedules)
13
23
  .where(and(eq(bookingPaymentSchedules.bookingId, bookingId), or(eq(bookingPaymentSchedules.status, "pending"), eq(bookingPaymentSchedules.status, "due"))))
14
24
  .orderBy(asc(bookingPaymentSchedules.dueDate), asc(bookingPaymentSchedules.createdAt))
15
25
  .limit(1),
16
26
  db
17
- .select()
27
+ .select({
28
+ id: invoices.id,
29
+ invoiceNumber: invoices.invoiceNumber,
30
+ invoiceType: invoices.invoiceType,
31
+ status: invoices.status,
32
+ currency: invoices.currency,
33
+ subtotalCents: invoices.subtotalCents,
34
+ taxCents: invoices.taxCents,
35
+ totalCents: invoices.totalCents,
36
+ paidCents: invoices.paidCents,
37
+ balanceDueCents: invoices.balanceDueCents,
38
+ issueDate: invoices.issueDate,
39
+ dueDate: invoices.dueDate,
40
+ })
18
41
  .from(invoices)
19
42
  .where(eq(invoices.bookingId, bookingId))
20
43
  .orderBy(desc(invoices.createdAt))
21
44
  .limit(1),
22
45
  db
23
- .select()
46
+ .select({
47
+ id: paymentSessions.id,
48
+ status: paymentSessions.status,
49
+ provider: paymentSessions.provider,
50
+ currency: paymentSessions.currency,
51
+ amountCents: paymentSessions.amountCents,
52
+ redirectUrl: paymentSessions.redirectUrl,
53
+ returnUrl: paymentSessions.returnUrl,
54
+ cancelUrl: paymentSessions.cancelUrl,
55
+ expiresAt: paymentSessions.expiresAt,
56
+ paymentMethod: paymentSessions.paymentMethod,
57
+ externalReference: paymentSessions.externalReference,
58
+ })
24
59
  .from(paymentSessions)
25
60
  .where(eq(paymentSessions.bookingId, bookingId))
26
61
  .orderBy(desc(paymentSessions.createdAt))
@@ -121,9 +156,6 @@ function buildReminderQueueSummary() {
121
156
  failed: 0,
122
157
  };
123
158
  }
124
- function isRetryableReminderRun(run) {
125
- return run?.status === "failed";
126
- }
127
159
  async function getReminderRuleById(db, reminderRuleId) {
128
160
  const [rule] = await db
129
161
  .select()
@@ -140,20 +172,6 @@ async function getReminderRunById(db, reminderRunId) {
140
172
  .limit(1);
141
173
  return run ?? null;
142
174
  }
143
- async function markReminderRunQueued(db, reminderRunId, now, recipient) {
144
- const [run] = await db
145
- .update(notificationReminderRuns)
146
- .set({
147
- status: "queued",
148
- errorMessage: null,
149
- recipient: recipient ?? undefined,
150
- processedAt: now,
151
- updatedAt: now,
152
- })
153
- .where(eq(notificationReminderRuns.id, reminderRunId))
154
- .returning();
155
- return run ?? null;
156
- }
157
175
  async function markReminderRunSkipped(db, reminderRunId, now, errorMessage) {
158
176
  const [run] = await db
159
177
  .update(notificationReminderRuns)
@@ -194,419 +212,33 @@ async function markReminderRunSent(db, reminderRunId, now, notificationDeliveryI
194
212
  .returning();
195
213
  return run ?? null;
196
214
  }
197
- async function enqueueReminderRun(db, enqueueDelivery, run, now) {
198
- const queuedRun = await markReminderRunQueued(db, run.id, now, run.recipient);
199
- if (!queuedRun) {
200
- return null;
201
- }
202
- try {
203
- await enqueueDelivery({ reminderRunId: queuedRun.id });
204
- return queuedRun;
205
- }
206
- catch (error) {
207
- const message = error instanceof Error ? error.message : "Failed to enqueue reminder delivery";
208
- return markReminderRunFailed(db, queuedRun.id, new Date(), message);
209
- }
210
- }
211
- async function queueBookingPaymentScheduleReminder(db, enqueueDelivery, rule, schedule, now) {
212
- const runDate = toDateString(startOfUtcDay(now));
213
- const dedupeKey = buildReminderDedupeKey(rule.id, schedule.id, runDate);
214
- const [existingRun] = await db
215
- .select()
216
- .from(notificationReminderRuns)
217
- .where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
218
- .limit(1);
219
- if (existingRun && !isRetryableReminderRun(existingRun)) {
220
- return null;
221
- }
222
- const [booking] = await db
223
- .select()
224
- .from(bookings)
225
- .where(eq(bookings.id, schedule.bookingId))
226
- .limit(1);
227
- const reminderRun = existingRun && isRetryableReminderRun(existingRun)
228
- ? existingRun
229
- : ((await db
230
- .insert(notificationReminderRuns)
231
- .values({
232
- reminderRuleId: rule.id,
233
- targetType: rule.targetType,
234
- targetId: schedule.id,
235
- dedupeKey,
236
- bookingId: schedule.bookingId,
237
- personId: booking?.personId ?? null,
238
- organizationId: booking?.organizationId ?? null,
239
- paymentSessionId: null,
240
- notificationDeliveryId: null,
241
- status: "queued",
242
- recipient: null,
243
- scheduledFor: now,
244
- processedAt: now,
245
- errorMessage: null,
246
- metadata: {
247
- dueDate: schedule.dueDate,
248
- relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
249
- bookingNumber: booking?.bookingNumber ?? null,
250
- },
251
- })
252
- .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
253
- .returning())[0] ?? null);
254
- if (!reminderRun) {
255
- return null;
256
- }
257
- if (!booking) {
258
- return markReminderRunSkipped(db, reminderRun.id, now, "Booking not found for payment schedule");
259
- }
260
- const [participants] = await Promise.all([
261
- db
262
- .select({
263
- id: bookingTravelers.id,
264
- firstName: bookingTravelers.firstName,
265
- lastName: bookingTravelers.lastName,
266
- email: bookingTravelers.email,
267
- participantType: bookingTravelers.participantType,
268
- isPrimary: bookingTravelers.isPrimary,
269
- })
270
- .from(bookingTravelers)
271
- .where(eq(bookingTravelers.bookingId, booking.id))
272
- .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt),
273
- ]);
274
- const recipient = resolveReminderRecipient(booking, participants);
275
- if (!recipient?.email) {
276
- return markReminderRunSkipped(db, reminderRun.id, now, "No traveler email available for booking payment reminder");
277
- }
278
- return enqueueReminderRun(db, enqueueDelivery, { ...reminderRun, recipient: recipient.email }, now);
279
- }
280
- async function queueInvoiceReminder(db, enqueueDelivery, rule, invoice, now) {
281
- const runDate = toDateString(startOfUtcDay(now));
282
- const dedupeKey = buildReminderDedupeKey(rule.id, invoice.id, runDate);
283
- const [existingRun] = await db
284
- .select()
285
- .from(notificationReminderRuns)
286
- .where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
287
- .limit(1);
288
- if (existingRun && !isRetryableReminderRun(existingRun)) {
289
- return null;
290
- }
291
- const [booking] = await db
292
- .select()
293
- .from(bookings)
294
- .where(eq(bookings.id, invoice.bookingId))
295
- .limit(1);
296
- const reminderRun = existingRun && isRetryableReminderRun(existingRun)
297
- ? existingRun
298
- : ((await db
299
- .insert(notificationReminderRuns)
300
- .values({
301
- reminderRuleId: rule.id,
302
- targetType: "invoice",
303
- targetId: invoice.id,
304
- dedupeKey,
305
- bookingId: invoice.bookingId,
306
- personId: invoice.personId ?? booking?.personId ?? null,
307
- organizationId: invoice.organizationId ?? booking?.organizationId ?? null,
308
- paymentSessionId: null,
309
- notificationDeliveryId: null,
310
- status: "queued",
311
- recipient: null,
312
- scheduledFor: now,
313
- processedAt: now,
314
- errorMessage: null,
315
- metadata: {
316
- dueDate: invoice.dueDate,
317
- relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
318
- bookingNumber: booking?.bookingNumber ?? null,
319
- invoiceNumber: invoice.invoiceNumber,
320
- invoiceType: invoice.invoiceType,
321
- },
322
- })
323
- .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
324
- .returning())[0] ?? null);
325
- if (!reminderRun) {
326
- return null;
327
- }
328
- if (!booking) {
329
- return markReminderRunSkipped(db, reminderRun.id, now, "Booking not found for invoice reminder");
330
- }
331
- const [participants] = await Promise.all([
332
- db
333
- .select({
334
- id: bookingTravelers.id,
335
- firstName: bookingTravelers.firstName,
336
- lastName: bookingTravelers.lastName,
337
- email: bookingTravelers.email,
338
- participantType: bookingTravelers.participantType,
339
- isPrimary: bookingTravelers.isPrimary,
340
- })
341
- .from(bookingTravelers)
342
- .where(eq(bookingTravelers.bookingId, booking.id))
343
- .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt),
344
- ]);
345
- const recipient = resolveReminderRecipient(booking, participants);
346
- if (!recipient?.email) {
347
- return markReminderRunSkipped(db, reminderRun.id, now, "No traveler email available for invoice reminder");
348
- }
349
- return enqueueReminderRun(db, enqueueDelivery, { ...reminderRun, recipient: recipient.email }, now);
350
- }
351
- async function sendBookingPaymentScheduleReminder(db, dispatcher, rule, schedule, now) {
352
- const runDate = toDateString(startOfUtcDay(now));
353
- const dedupeKey = buildReminderDedupeKey(rule.id, schedule.id, runDate);
354
- const [existingRun] = await db
355
- .select({ id: notificationReminderRuns.id })
356
- .from(notificationReminderRuns)
357
- .where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
358
- .limit(1);
359
- if (existingRun) {
360
- return null;
361
- }
362
- const [booking] = await db
363
- .select()
364
- .from(bookings)
365
- .where(eq(bookings.id, schedule.bookingId))
366
- .limit(1);
367
- if (!booking) {
368
- const [run] = await db
369
- .insert(notificationReminderRuns)
370
- .values({
371
- reminderRuleId: rule.id,
372
- targetType: rule.targetType,
373
- targetId: schedule.id,
374
- dedupeKey,
375
- bookingId: schedule.bookingId,
376
- personId: null,
377
- organizationId: null,
378
- paymentSessionId: null,
379
- notificationDeliveryId: null,
380
- status: "skipped",
381
- recipient: null,
382
- scheduledFor: now,
383
- processedAt: now,
384
- errorMessage: "Booking not found for payment schedule",
385
- metadata: {
386
- dueDate: schedule.dueDate,
387
- relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
388
- },
389
- })
390
- .returning();
391
- return run ?? null;
392
- }
393
- const [participants, items, paymentContext] = await Promise.all([
394
- db
395
- .select({
396
- id: bookingTravelers.id,
397
- firstName: bookingTravelers.firstName,
398
- lastName: bookingTravelers.lastName,
399
- email: bookingTravelers.email,
400
- participantType: bookingTravelers.participantType,
401
- isPrimary: bookingTravelers.isPrimary,
402
- })
403
- .from(bookingTravelers)
404
- .where(eq(bookingTravelers.bookingId, booking.id))
405
- .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt),
406
- listBookingNotificationItems(db, booking.id),
407
- getBookingPaymentNotificationContext(db, booking.id),
408
- ]);
409
- const recipient = resolveReminderRecipient(booking, participants);
410
- const [processingRun] = await db
411
- .insert(notificationReminderRuns)
412
- .values({
413
- reminderRuleId: rule.id,
414
- targetType: rule.targetType,
415
- targetId: schedule.id,
416
- dedupeKey,
417
- bookingId: booking.id,
418
- personId: booking.personId ?? null,
419
- organizationId: booking.organizationId ?? null,
420
- paymentSessionId: null,
421
- notificationDeliveryId: null,
422
- status: "processing",
423
- recipient: recipient?.email ?? null,
424
- scheduledFor: now,
425
- processedAt: now,
426
- errorMessage: null,
427
- metadata: {
428
- dueDate: schedule.dueDate,
429
- relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
430
- bookingNumber: booking.bookingNumber,
431
- },
432
- })
433
- .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
434
- .returning();
435
- if (!processingRun) {
436
- return null;
437
- }
438
- if (!recipient?.email) {
439
- return markReminderRunSkipped(db, processingRun.id, now, "No traveler email available for booking payment reminder");
440
- }
441
- try {
442
- const delivery = await sendNotification(db, dispatcher, {
443
- templateId: rule.templateId ?? null,
444
- templateSlug: rule.templateSlug ?? null,
445
- channel: rule.channel,
446
- provider: rule.provider ?? null,
447
- to: recipient.email,
448
- data: {
449
- bookingId: booking.id,
450
- bookingNumber: booking.bookingNumber,
451
- dueDate: schedule.dueDate,
452
- amountCents: schedule.amountCents,
453
- currency: schedule.currency,
454
- scheduleType: schedule.scheduleType,
455
- reminderOffsetDays: rule.relativeDaysFromDueDate,
456
- traveler: {
457
- firstName: recipient.firstName,
458
- lastName: recipient.lastName,
459
- email: recipient.email,
460
- participantType: recipient.participantType,
461
- isPrimary: recipient.isPrimary,
462
- },
463
- travelers: participants,
464
- booking: {
465
- id: booking.id,
466
- bookingNumber: booking.bookingNumber,
467
- startDate: booking.startDate,
468
- endDate: booking.endDate,
469
- sellCurrency: booking.sellCurrency,
470
- sellAmountCents: booking.sellAmountCents,
471
- },
472
- ...serializeBookingPaymentContext(paymentContext, schedule),
473
- items,
474
- },
475
- targetType: "booking_payment_schedule",
476
- targetId: schedule.id,
477
- bookingId: booking.id,
478
- personId: booking.personId ?? null,
479
- organizationId: booking.organizationId ?? null,
480
- metadata: {
481
- reminderRuleId: rule.id,
482
- reminderRunId: processingRun.id,
483
- },
484
- scheduledFor: now.toISOString(),
485
- });
486
- return markReminderRunSent(db, processingRun.id, new Date(), delivery?.id ?? null);
487
- }
488
- catch (error) {
489
- const message = error instanceof Error ? error.message : "Notification reminder failed";
490
- return markReminderRunFailed(db, processingRun.id, new Date(), message);
491
- }
492
- }
493
- async function sendInvoiceReminder(db, dispatcher, rule, invoice, now) {
494
- const runDate = toDateString(startOfUtcDay(now));
495
- const dedupeKey = buildReminderDedupeKey(rule.id, invoice.id, runDate);
496
- const [existingRun] = await db
497
- .select({ id: notificationReminderRuns.id })
498
- .from(notificationReminderRuns)
499
- .where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
500
- .limit(1);
501
- if (existingRun) {
502
- return null;
503
- }
504
- const [booking] = await db
505
- .select()
506
- .from(bookings)
507
- .where(eq(bookings.id, invoice.bookingId))
508
- .limit(1);
509
- if (!booking) {
510
- const [run] = await db
511
- .insert(notificationReminderRuns)
512
- .values({
513
- reminderRuleId: rule.id,
514
- targetType: "invoice",
515
- targetId: invoice.id,
516
- dedupeKey,
517
- bookingId: invoice.bookingId,
518
- personId: invoice.personId ?? null,
519
- organizationId: invoice.organizationId ?? null,
520
- paymentSessionId: null,
521
- notificationDeliveryId: null,
522
- status: "skipped",
523
- recipient: null,
524
- scheduledFor: now,
525
- processedAt: now,
526
- errorMessage: "Booking not found for invoice reminder",
527
- metadata: {
528
- dueDate: invoice.dueDate,
529
- relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
530
- invoiceNumber: invoice.invoiceNumber,
531
- invoiceType: invoice.invoiceType,
532
- },
533
- })
534
- .returning();
535
- return run ?? null;
536
- }
537
- const [participants] = await Promise.all([
538
- db
539
- .select({
540
- id: bookingTravelers.id,
541
- firstName: bookingTravelers.firstName,
542
- lastName: bookingTravelers.lastName,
543
- email: bookingTravelers.email,
544
- participantType: bookingTravelers.participantType,
545
- isPrimary: bookingTravelers.isPrimary,
546
- })
547
- .from(bookingTravelers)
548
- .where(eq(bookingTravelers.bookingId, booking.id))
549
- .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt),
550
- ]);
551
- const recipient = resolveReminderRecipient(booking, participants);
552
- const [processingRun] = await db
553
- .insert(notificationReminderRuns)
554
- .values({
555
- reminderRuleId: rule.id,
556
- targetType: "invoice",
557
- targetId: invoice.id,
558
- dedupeKey,
559
- bookingId: booking.id,
560
- personId: invoice.personId ?? booking.personId ?? null,
561
- organizationId: invoice.organizationId ?? booking.organizationId ?? null,
562
- paymentSessionId: null,
563
- notificationDeliveryId: null,
564
- status: "processing",
565
- recipient: recipient?.email ?? null,
566
- scheduledFor: now,
567
- processedAt: now,
568
- errorMessage: null,
569
- metadata: {
570
- dueDate: invoice.dueDate,
571
- relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
572
- bookingNumber: booking.bookingNumber,
573
- invoiceNumber: invoice.invoiceNumber,
574
- invoiceType: invoice.invoiceType,
575
- },
576
- })
577
- .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
578
- .returning();
579
- if (!processingRun) {
580
- return null;
581
- }
582
- if (!recipient?.email) {
583
- return markReminderRunSkipped(db, processingRun.id, now, "No traveler email available for invoice reminder");
584
- }
585
- try {
586
- const delivery = await sendInvoiceNotification(db, dispatcher, invoice.id, {
587
- templateId: rule.templateId ?? null,
588
- templateSlug: rule.templateSlug ?? null,
589
- channel: rule.channel,
590
- provider: rule.provider ?? null,
591
- to: recipient.email,
592
- data: {
593
- reminderOffsetDays: rule.relativeDaysFromDueDate,
594
- reminderRunId: processingRun.id,
595
- },
596
- metadata: {
597
- reminderRuleId: rule.id,
598
- reminderRunId: processingRun.id,
599
- },
600
- scheduledFor: now.toISOString(),
601
- });
602
- return markReminderRunSent(db, processingRun.id, new Date(), delivery?.id ?? null);
603
- }
604
- catch (error) {
605
- const message = error instanceof Error ? error.message : "Invoice reminder failed";
606
- return markReminderRunFailed(db, processingRun.id, new Date(), message);
215
+ async function resolveChannelOverride(db, run, rule) {
216
+ const stageChannelId = run.metadata && typeof run.metadata === "object"
217
+ ? run.metadata.stageChannelId
218
+ : undefined;
219
+ if (stageChannelId) {
220
+ const [stageChannel] = await db
221
+ .select()
222
+ .from(notificationReminderStageChannels)
223
+ .where(eq(notificationReminderStageChannels.id, stageChannelId))
224
+ .limit(1);
225
+ if (stageChannel) {
226
+ return {
227
+ channel: stageChannel.channel,
228
+ templateId: stageChannel.templateId ?? null,
229
+ templateSlug: stageChannel.templateSlug ?? null,
230
+ provider: stageChannel.provider ?? null,
231
+ };
232
+ }
607
233
  }
234
+ return {
235
+ channel: rule.channel,
236
+ templateId: rule.templateId ?? null,
237
+ templateSlug: rule.templateSlug ?? null,
238
+ provider: rule.provider ?? null,
239
+ };
608
240
  }
609
- async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rule, now) {
241
+ async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rule, now, channelOverride) {
610
242
  const [schedule] = await db
611
243
  .select()
612
244
  .from(bookingPaymentSchedules)
@@ -616,7 +248,22 @@ async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rul
616
248
  return markReminderRunSkipped(db, run.id, now, "Booking payment schedule not found for reminder run");
617
249
  }
618
250
  const [booking] = await db
619
- .select()
251
+ .select({
252
+ id: bookings.id,
253
+ bookingNumber: bookings.bookingNumber,
254
+ status: bookings.status,
255
+ personId: bookings.personId,
256
+ organizationId: bookings.organizationId,
257
+ contactFirstName: bookings.contactFirstName,
258
+ contactLastName: bookings.contactLastName,
259
+ contactEmail: bookings.contactEmail,
260
+ contactPhone: bookings.contactPhone,
261
+ contactPreferredLanguage: bookings.contactPreferredLanguage,
262
+ sellCurrency: bookings.sellCurrency,
263
+ sellAmountCents: bookings.sellAmountCents,
264
+ startDate: bookings.startDate,
265
+ endDate: bookings.endDate,
266
+ })
620
267
  .from(bookings)
621
268
  .where(eq(bookings.id, schedule.bookingId))
622
269
  .limit(1);
@@ -647,10 +294,10 @@ async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rul
647
294
  }
648
295
  try {
649
296
  const delivery = await sendNotification(db, dispatcher, {
650
- templateId: rule.templateId ?? null,
651
- templateSlug: rule.templateSlug ?? null,
652
- channel: rule.channel,
653
- provider: rule.provider ?? null,
297
+ templateId: channelOverride.templateId,
298
+ templateSlug: channelOverride.templateSlug,
299
+ channel: channelOverride.channel,
300
+ provider: channelOverride.provider,
654
301
  to: recipientEmail,
655
302
  data: {
656
303
  bookingId: booking.id,
@@ -659,7 +306,6 @@ async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rul
659
306
  amountCents: schedule.amountCents,
660
307
  currency: schedule.currency,
661
308
  scheduleType: schedule.scheduleType,
662
- reminderOffsetDays: rule.relativeDaysFromDueDate,
663
309
  traveler: traveler
664
310
  ? {
665
311
  firstName: traveler.firstName,
@@ -699,15 +345,14 @@ async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rul
699
345
  return markReminderRunFailed(db, run.id, new Date(), message);
700
346
  }
701
347
  }
702
- async function sendQueuedInvoiceReminder(db, dispatcher, run, rule, now) {
348
+ async function sendQueuedInvoiceReminder(db, dispatcher, run, rule, now, channelOverride) {
703
349
  const delivery = await sendInvoiceNotification(db, dispatcher, run.targetId, {
704
- templateId: rule.templateId ?? null,
705
- templateSlug: rule.templateSlug ?? null,
706
- channel: rule.channel,
707
- provider: rule.provider ?? null,
350
+ templateId: channelOverride.templateId,
351
+ templateSlug: channelOverride.templateSlug,
352
+ channel: channelOverride.channel,
353
+ provider: channelOverride.provider,
708
354
  to: run.recipient ?? undefined,
709
355
  data: {
710
- reminderOffsetDays: rule.relativeDaysFromDueDate,
711
356
  reminderRunId: run.id,
712
357
  },
713
358
  metadata: {
@@ -893,59 +538,7 @@ export async function bookingIsPaidInFullForNotification(db, bookingId) {
893
538
  return !(await hasOutstandingBookingBalance(db, bookingId));
894
539
  }
895
540
  export async function queueDueReminders(db, input = {}, enqueueDelivery) {
896
- const now = toTimestamp(input.now) ?? new Date();
897
- const today = startOfUtcDay(now);
898
- const activeRules = await db
899
- .select()
900
- .from(notificationReminderRules)
901
- .where(eq(notificationReminderRules.status, "active"))
902
- .orderBy(notificationReminderRules.createdAt);
903
- const summary = buildReminderQueueSummary();
904
- for (const rule of activeRules) {
905
- const matchingDueDate = toDateString(addUtcDays(today, -rule.relativeDaysFromDueDate));
906
- if (rule.targetType === "booking_payment_schedule") {
907
- const schedules = await db
908
- .select()
909
- .from(bookingPaymentSchedules)
910
- .where(and(eq(bookingPaymentSchedules.dueDate, matchingDueDate), or(eq(bookingPaymentSchedules.status, "pending"), eq(bookingPaymentSchedules.status, "due"))))
911
- .orderBy(bookingPaymentSchedules.createdAt);
912
- for (const schedule of schedules) {
913
- const run = await queueBookingPaymentScheduleReminder(db, enqueueDelivery, rule, schedule, now);
914
- if (!run) {
915
- continue;
916
- }
917
- summary.processed += 1;
918
- if (run.status === "queued")
919
- summary.queued += 1;
920
- if (run.status === "skipped")
921
- summary.skipped += 1;
922
- if (run.status === "failed")
923
- summary.failed += 1;
924
- }
925
- continue;
926
- }
927
- if (rule.targetType === "invoice") {
928
- const dueInvoices = await db
929
- .select()
930
- .from(invoices)
931
- .where(and(eq(invoices.dueDate, matchingDueDate), gt(invoices.balanceDueCents, 0), or(eq(invoices.invoiceType, "invoice"), eq(invoices.invoiceType, "proforma")), or(eq(invoices.status, "sent"), eq(invoices.status, "partially_paid"), eq(invoices.status, "overdue"))))
932
- .orderBy(invoices.createdAt);
933
- for (const invoice of dueInvoices) {
934
- const run = await queueInvoiceReminder(db, enqueueDelivery, rule, invoice, now);
935
- if (!run) {
936
- continue;
937
- }
938
- summary.processed += 1;
939
- if (run.status === "queued")
940
- summary.queued += 1;
941
- if (run.status === "skipped")
942
- summary.skipped += 1;
943
- if (run.status === "failed")
944
- summary.failed += 1;
945
- }
946
- }
947
- }
948
- return summary;
541
+ return queueStageBasedDueReminders(db, enqueueDelivery, input);
949
542
  }
950
543
  export async function deliverReminderRun(db, dispatcher, input) {
951
544
  const now = new Date();
@@ -970,12 +563,13 @@ export async function deliverReminderRun(db, dispatcher, input) {
970
563
  if (!rule) {
971
564
  return markReminderRunFailed(db, run.id, new Date(), "Reminder rule not found");
972
565
  }
566
+ const channelOverride = await resolveChannelOverride(db, run, rule);
973
567
  try {
974
568
  if (run.targetType === "booking_payment_schedule") {
975
- return await sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rule, now);
569
+ return await sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rule, now, channelOverride);
976
570
  }
977
571
  if (run.targetType === "invoice") {
978
- return await sendQueuedInvoiceReminder(db, dispatcher, run, rule, now);
572
+ return await sendQueuedInvoiceReminder(db, dispatcher, run, rule, now, channelOverride);
979
573
  }
980
574
  return markReminderRunSkipped(db, run.id, now, "Unsupported reminder target type");
981
575
  }
@@ -985,57 +579,310 @@ export async function deliverReminderRun(db, dispatcher, input) {
985
579
  }
986
580
  }
987
581
  export async function runDueReminders(db, dispatcher, input = {}) {
988
- const now = toTimestamp(input.now) ?? new Date();
989
- const today = startOfUtcDay(now);
990
- const activeRules = await db
582
+ return runStageBasedDueReminders(db, dispatcher, input);
583
+ }
584
+ function buildStageDedupeKey(ruleId, targetId, runDate, stageId, channel) {
585
+ return `${ruleId}:${targetId}:${runDate}:${stageId}:${channel}`;
586
+ }
587
+ async function fetchScheduleRow(db, scheduleId) {
588
+ const [row] = await db
991
589
  .select()
992
- .from(notificationReminderRules)
993
- .where(eq(notificationReminderRules.status, "active"))
994
- .orderBy(notificationReminderRules.createdAt);
995
- const summary = buildReminderSweepSummary();
996
- for (const rule of activeRules) {
997
- const matchingDueDate = toDateString(addUtcDays(today, -rule.relativeDaysFromDueDate));
998
- if (rule.targetType === "booking_payment_schedule") {
999
- const schedules = await db
1000
- .select()
1001
- .from(bookingPaymentSchedules)
1002
- .where(and(eq(bookingPaymentSchedules.dueDate, matchingDueDate), or(eq(bookingPaymentSchedules.status, "pending"), eq(bookingPaymentSchedules.status, "due"))))
1003
- .orderBy(bookingPaymentSchedules.createdAt);
1004
- for (const schedule of schedules) {
1005
- const run = await sendBookingPaymentScheduleReminder(db, dispatcher, rule, schedule, now);
1006
- if (!run) {
1007
- continue;
1008
- }
1009
- summary.processed += 1;
1010
- if (run.status === "sent")
1011
- summary.sent += 1;
1012
- if (run.status === "skipped")
1013
- summary.skipped += 1;
1014
- if (run.status === "failed")
1015
- summary.failed += 1;
590
+ .from(bookingPaymentSchedules)
591
+ .where(eq(bookingPaymentSchedules.id, scheduleId))
592
+ .limit(1);
593
+ return row ?? null;
594
+ }
595
+ async function fetchInvoiceRow(db, invoiceId) {
596
+ const [row] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
597
+ return row ?? null;
598
+ }
599
+ async function emitStageChannelRun(db, dispatcher, rule, stage, channelRow, target, recipient, scheduledAt, sendCountAtFire, enqueueDelivery, now) {
600
+ const runDate = toDateString(startOfUtcDay(scheduledAt));
601
+ const dedupeKey = buildStageDedupeKey(rule.id, target.id, runDate, stage.id, channelRow.channel);
602
+ const [existingRun] = await db
603
+ .select()
604
+ .from(notificationReminderRuns)
605
+ .where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
606
+ .limit(1);
607
+ if (existingRun && existingRun.status !== "failed") {
608
+ return { status: "skipped", runId: existingRun.id };
609
+ }
610
+ const baseValues = {
611
+ reminderRuleId: rule.id,
612
+ targetType: rule.targetType,
613
+ targetId: target.id,
614
+ dedupeKey,
615
+ bookingId: target.bookingId,
616
+ personId: null,
617
+ organizationId: null,
618
+ paymentSessionId: null,
619
+ notificationDeliveryId: null,
620
+ recipient: recipient?.email ?? null,
621
+ scheduledFor: scheduledAt,
622
+ processedAt: now,
623
+ errorMessage: null,
624
+ metadata: {
625
+ stageId: stage.id,
626
+ stageOrderIndex: stage.orderIndex,
627
+ stageChannelId: channelRow.id,
628
+ channel: channelRow.channel,
629
+ anchor: stage.anchor,
630
+ sendCountAtFire,
631
+ ruleSlug: rule.slug,
632
+ },
633
+ };
634
+ if (!recipient?.email) {
635
+ const [run] = existingRun
636
+ ? await db
637
+ .update(notificationReminderRuns)
638
+ .set({ ...baseValues, status: "skipped", errorMessage: "no_recipient" })
639
+ .where(eq(notificationReminderRuns.id, existingRun.id))
640
+ .returning()
641
+ : await db
642
+ .insert(notificationReminderRuns)
643
+ .values({ ...baseValues, status: "skipped", errorMessage: "no_recipient" })
644
+ .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
645
+ .returning();
646
+ return { status: "skipped", runId: run?.id ?? null };
647
+ }
648
+ if (enqueueDelivery && !dispatcher) {
649
+ const [queuedRun] = existingRun
650
+ ? await db
651
+ .update(notificationReminderRuns)
652
+ .set({ ...baseValues, status: "queued" })
653
+ .where(eq(notificationReminderRuns.id, existingRun.id))
654
+ .returning()
655
+ : await db
656
+ .insert(notificationReminderRuns)
657
+ .values({ ...baseValues, status: "queued" })
658
+ .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
659
+ .returning();
660
+ if (!queuedRun)
661
+ return { status: "skipped", runId: null };
662
+ try {
663
+ await enqueueDelivery({ reminderRunId: queuedRun.id });
664
+ return { status: "queued", runId: queuedRun.id };
665
+ }
666
+ catch (error) {
667
+ const message = error instanceof Error ? error.message : "enqueue_failed";
668
+ const failed = await markReminderRunFailed(db, queuedRun.id, new Date(), message);
669
+ return { status: "failed", runId: failed?.id ?? null };
670
+ }
671
+ }
672
+ if (!dispatcher) {
673
+ return { status: "skipped", runId: null };
674
+ }
675
+ const [processingRun] = existingRun
676
+ ? await db
677
+ .update(notificationReminderRuns)
678
+ .set({ ...baseValues, status: "processing" })
679
+ .where(eq(notificationReminderRuns.id, existingRun.id))
680
+ .returning()
681
+ : await db
682
+ .insert(notificationReminderRuns)
683
+ .values({ ...baseValues, status: "processing" })
684
+ .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
685
+ .returning();
686
+ if (!processingRun) {
687
+ return { status: "skipped", runId: null };
688
+ }
689
+ try {
690
+ const data = {
691
+ reminderRuleId: rule.id,
692
+ reminderRunId: processingRun.id,
693
+ stageId: stage.id,
694
+ stageOrderIndex: stage.orderIndex,
695
+ sendCountAtFire,
696
+ };
697
+ let delivery = null;
698
+ if (rule.targetType === "invoice") {
699
+ const invoice = await fetchInvoiceRow(db, target.id);
700
+ if (!invoice) {
701
+ return {
702
+ status: "skipped",
703
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), "invoice_not_found"))
704
+ ?.id ?? null,
705
+ };
706
+ }
707
+ delivery = await sendInvoiceNotification(db, dispatcher, invoice.id, {
708
+ templateId: channelRow.templateId ?? null,
709
+ templateSlug: channelRow.templateSlug ?? null,
710
+ channel: channelRow.channel,
711
+ provider: channelRow.provider ?? null,
712
+ to: recipient.email,
713
+ data,
714
+ metadata: { reminderRuleId: rule.id, reminderRunId: processingRun.id, stageId: stage.id },
715
+ scheduledFor: scheduledAt.toISOString(),
716
+ });
717
+ }
718
+ else if (rule.targetType === "booking_payment_schedule") {
719
+ const schedule = await fetchScheduleRow(db, target.id);
720
+ if (!schedule) {
721
+ return {
722
+ status: "skipped",
723
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), "schedule_not_found"))
724
+ ?.id ?? null,
725
+ };
1016
726
  }
727
+ delivery = await sendNotification(db, dispatcher, {
728
+ templateId: channelRow.templateId ?? null,
729
+ templateSlug: channelRow.templateSlug ?? null,
730
+ channel: channelRow.channel,
731
+ provider: channelRow.provider ?? null,
732
+ to: recipient.email,
733
+ data: {
734
+ ...data,
735
+ bookingId: schedule.bookingId,
736
+ dueDate: schedule.dueDate,
737
+ amountCents: schedule.amountCents,
738
+ currency: schedule.currency,
739
+ },
740
+ targetType: "booking_payment_schedule",
741
+ targetId: schedule.id,
742
+ bookingId: schedule.bookingId,
743
+ metadata: { reminderRuleId: rule.id, reminderRunId: processingRun.id, stageId: stage.id },
744
+ scheduledFor: scheduledAt.toISOString(),
745
+ });
746
+ }
747
+ else {
748
+ return {
749
+ status: "skipped",
750
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), "unsupported_target_type"))?.id ?? null,
751
+ };
752
+ }
753
+ const sent = await markReminderRunSent(db, processingRun.id, new Date(), delivery?.id ?? null);
754
+ return { status: "sent", runId: sent?.id ?? null };
755
+ }
756
+ catch (error) {
757
+ const message = error instanceof Error ? error.message : "delivery_failed";
758
+ const failed = await markReminderRunFailed(db, processingRun.id, new Date(), message);
759
+ return { status: "failed", runId: failed?.id ?? null };
760
+ }
761
+ }
762
+ async function processStageRuleTargets(db, options) {
763
+ const tally = { processed: 0, sent: 0, queued: 0, skipped: 0, failed: 0 };
764
+ for (const target of options.targets) {
765
+ const history = await loadHistory(db, options.rule.id, target.id);
766
+ const decision = evaluateStage(options.rule, options.stages, target, history, options.today);
767
+ if (!decision.fire)
768
+ continue;
769
+ const channels = await listChannelsForStage(db, decision.stage.id);
770
+ if (channels.length === 0)
771
+ continue;
772
+ const booking = target.bookingId
773
+ ? ((await db
774
+ .select({
775
+ id: bookings.id,
776
+ bookingNumber: bookings.bookingNumber,
777
+ personId: bookings.personId,
778
+ organizationId: bookings.organizationId,
779
+ contactFirstName: bookings.contactFirstName,
780
+ contactLastName: bookings.contactLastName,
781
+ contactEmail: bookings.contactEmail,
782
+ contactPhone: bookings.contactPhone,
783
+ contactPreferredLanguage: bookings.contactPreferredLanguage,
784
+ })
785
+ .from(bookings)
786
+ .where(eq(bookings.id, target.bookingId))
787
+ .limit(1))[0] ?? null)
788
+ : null;
789
+ const participants = booking
790
+ ? await db
791
+ .select({
792
+ id: bookingTravelers.id,
793
+ firstName: bookingTravelers.firstName,
794
+ lastName: bookingTravelers.lastName,
795
+ email: bookingTravelers.email,
796
+ participantType: bookingTravelers.participantType,
797
+ isPrimary: bookingTravelers.isPrimary,
798
+ })
799
+ .from(bookingTravelers)
800
+ .where(eq(bookingTravelers.bookingId, booking.id))
801
+ .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt)
802
+ : [];
803
+ const recipient = booking ? resolveReminderRecipient(booking, participants) : null;
804
+ if (await suppressedByGroup(db, recipient?.email ?? null, options.rule.suppressionGroup, options.settings, options.now)) {
805
+ tally.skipped += 1;
1017
806
  continue;
1018
807
  }
1019
- if (rule.targetType === "invoice") {
1020
- const dueInvoices = await db
1021
- .select()
1022
- .from(invoices)
1023
- .where(and(eq(invoices.dueDate, matchingDueDate), gt(invoices.balanceDueCents, 0), or(eq(invoices.invoiceType, "invoice"), eq(invoices.invoiceType, "proforma")), or(eq(invoices.status, "sent"), eq(invoices.status, "partially_paid"), eq(invoices.status, "overdue"))))
1024
- .orderBy(invoices.createdAt);
1025
- for (const invoice of dueInvoices) {
1026
- const run = await sendInvoiceReminder(db, dispatcher, rule, invoice, now);
1027
- if (!run) {
1028
- continue;
1029
- }
1030
- summary.processed += 1;
1031
- if (run.status === "sent")
1032
- summary.sent += 1;
1033
- if (run.status === "skipped")
1034
- summary.skipped += 1;
1035
- if (run.status === "failed")
1036
- summary.failed += 1;
808
+ const { scheduledAt } = applyQuietHours(options.now, decision.stage, options.settings);
809
+ for (const channelRow of channels) {
810
+ if (recipient?.email &&
811
+ (await exceedsRecipientRateLimit(db, recipient.email, channelRow.channel, options.settings, options.now))) {
812
+ tally.skipped += 1;
813
+ continue;
1037
814
  }
815
+ const result = await emitStageChannelRun(db, options.dispatcher, options.rule, decision.stage, channelRow, target, recipient ?? null, scheduledAt, decision.sendCountAtFire, options.enqueueDelivery, options.now);
816
+ tally.processed += 1;
817
+ if (result.status === "sent")
818
+ tally.sent += 1;
819
+ if (result.status === "queued")
820
+ tally.queued += 1;
821
+ if (result.status === "skipped")
822
+ tally.skipped += 1;
823
+ if (result.status === "failed")
824
+ tally.failed += 1;
1038
825
  }
1039
826
  }
827
+ return tally;
828
+ }
829
+ export async function runStageBasedDueReminders(db, dispatcher, input = {}) {
830
+ const now = toTimestamp(input.now) ?? new Date();
831
+ const today = startOfUtcDay(now);
832
+ const settings = await getNotificationSettings(db);
833
+ const rules = await listActiveRulesByPriority(db);
834
+ const summary = buildReminderSweepSummary();
835
+ for (const rule of rules) {
836
+ const stages = await listStagesForRule(db, rule.id);
837
+ if (stages.length === 0)
838
+ continue;
839
+ const targets = await fetchTargetsForRule(db, rule, stages, today);
840
+ if (targets.length === 0)
841
+ continue;
842
+ const tally = await processStageRuleTargets(db, {
843
+ rule,
844
+ stages,
845
+ targets,
846
+ settings,
847
+ today,
848
+ now,
849
+ dispatcher,
850
+ enqueueDelivery: null,
851
+ });
852
+ summary.processed += tally.processed;
853
+ summary.sent += tally.sent;
854
+ summary.skipped += tally.skipped;
855
+ summary.failed += tally.failed;
856
+ }
857
+ return summary;
858
+ }
859
+ export async function queueStageBasedDueReminders(db, enqueueDelivery, input = {}) {
860
+ const now = toTimestamp(input.now) ?? new Date();
861
+ const today = startOfUtcDay(now);
862
+ const settings = await getNotificationSettings(db);
863
+ const rules = await listActiveRulesByPriority(db);
864
+ const summary = buildReminderQueueSummary();
865
+ for (const rule of rules) {
866
+ const stages = await listStagesForRule(db, rule.id);
867
+ if (stages.length === 0)
868
+ continue;
869
+ const targets = await fetchTargetsForRule(db, rule, stages, today);
870
+ if (targets.length === 0)
871
+ continue;
872
+ const tally = await processStageRuleTargets(db, {
873
+ rule,
874
+ stages,
875
+ targets,
876
+ settings,
877
+ today,
878
+ now,
879
+ dispatcher: null,
880
+ enqueueDelivery,
881
+ });
882
+ summary.processed += tally.processed;
883
+ summary.queued += tally.queued;
884
+ summary.skipped += tally.skipped;
885
+ summary.failed += tally.failed;
886
+ }
1040
887
  return summary;
1041
888
  }