@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/routes.d.ts +428 -8
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +63 -1
- package/dist/schema.d.ts +729 -16
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +114 -1
- package/dist/service-reminders.d.ts +4 -2
- package/dist/service-reminders.d.ts.map +1 -1
- package/dist/service-reminders.js +392 -545
- package/dist/service-sequence.d.ts +113 -0
- package/dist/service-sequence.d.ts.map +1 -0
- package/dist/service-sequence.js +432 -0
- package/dist/service-stages.d.ts +23 -0
- package/dist/service-stages.d.ts.map +1 -0
- package/dist/service-stages.js +203 -0
- package/dist/service-templates.d.ts +10 -8
- package/dist/service-templates.d.ts.map +1 -1
- package/dist/service-templates.js +0 -3
- package/dist/service.d.ts +15 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +15 -0
- package/dist/tasks/deliver-reminder.d.ts +1 -1
- package/dist/validation.d.ts +143 -13
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +110 -7
- package/package.json +7 -7
|
@@ -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 {
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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:
|
|
651
|
-
templateSlug:
|
|
652
|
-
channel:
|
|
653
|
-
provider:
|
|
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:
|
|
705
|
-
templateSlug:
|
|
706
|
-
channel:
|
|
707
|
-
provider:
|
|
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
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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(
|
|
993
|
-
.where(eq(
|
|
994
|
-
.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
.
|
|
1023
|
-
.
|
|
1024
|
-
|
|
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
|
}
|