@voyant-travel/notifications 0.111.7

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.
Files changed (96) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +179 -0
  3. package/dist/index.d.ts +61 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +196 -0
  6. package/dist/liquid.d.ts +5 -0
  7. package/dist/liquid.d.ts.map +1 -0
  8. package/dist/liquid.js +156 -0
  9. package/dist/providers/local.d.ts +22 -0
  10. package/dist/providers/local.d.ts.map +1 -0
  11. package/dist/providers/local.js +23 -0
  12. package/dist/providers/voyant-cloud-email.d.ts +27 -0
  13. package/dist/providers/voyant-cloud-email.d.ts.map +1 -0
  14. package/dist/providers/voyant-cloud-email.js +73 -0
  15. package/dist/providers/voyant-cloud-sms.d.ts +26 -0
  16. package/dist/providers/voyant-cloud-sms.d.ts.map +1 -0
  17. package/dist/providers/voyant-cloud-sms.js +24 -0
  18. package/dist/routes.d.ts +1546 -0
  19. package/dist/routes.d.ts.map +1 -0
  20. package/dist/routes.js +337 -0
  21. package/dist/schema.d.ts +2119 -0
  22. package/dist/schema.d.ts.map +1 -0
  23. package/dist/schema.js +356 -0
  24. package/dist/service-booking-document-lifecycle.d.ts +99 -0
  25. package/dist/service-booking-document-lifecycle.d.ts.map +1 -0
  26. package/dist/service-booking-document-lifecycle.js +259 -0
  27. package/dist/service-booking-documents.d.ts +256 -0
  28. package/dist/service-booking-documents.d.ts.map +1 -0
  29. package/dist/service-booking-documents.js +323 -0
  30. package/dist/service-deliveries.d.ts +183 -0
  31. package/dist/service-deliveries.d.ts.map +1 -0
  32. package/dist/service-deliveries.js +413 -0
  33. package/dist/service-delivery-metadata.d.ts +42 -0
  34. package/dist/service-delivery-metadata.d.ts.map +1 -0
  35. package/dist/service-delivery-metadata.js +114 -0
  36. package/dist/service-reminder-authoring.d.ts +33 -0
  37. package/dist/service-reminder-authoring.d.ts.map +1 -0
  38. package/dist/service-reminder-authoring.js +247 -0
  39. package/dist/service-reminder-booking-context.d.ts +94 -0
  40. package/dist/service-reminder-booking-context.d.ts.map +1 -0
  41. package/dist/service-reminder-booking-context.js +164 -0
  42. package/dist/service-reminder-events.d.ts +33 -0
  43. package/dist/service-reminder-events.d.ts.map +1 -0
  44. package/dist/service-reminder-events.js +178 -0
  45. package/dist/service-reminder-run-state.d.ts +114 -0
  46. package/dist/service-reminder-run-state.d.ts.map +1 -0
  47. package/dist/service-reminder-run-state.js +100 -0
  48. package/dist/service-reminder-stage-runs.d.ts +6 -0
  49. package/dist/service-reminder-stage-runs.d.ts.map +1 -0
  50. package/dist/service-reminder-stage-runs.js +310 -0
  51. package/dist/service-reminders.d.ts +30 -0
  52. package/dist/service-reminders.d.ts.map +1 -0
  53. package/dist/service-reminders.js +189 -0
  54. package/dist/service-sequence-targets.d.ts +50 -0
  55. package/dist/service-sequence-targets.d.ts.map +1 -0
  56. package/dist/service-sequence-targets.js +136 -0
  57. package/dist/service-sequence.d.ts +68 -0
  58. package/dist/service-sequence.d.ts.map +1 -0
  59. package/dist/service-sequence.js +316 -0
  60. package/dist/service-shared.d.ts +107 -0
  61. package/dist/service-shared.d.ts.map +1 -0
  62. package/dist/service-shared.js +159 -0
  63. package/dist/service-stages.d.ts +23 -0
  64. package/dist/service-stages.d.ts.map +1 -0
  65. package/dist/service-stages.js +203 -0
  66. package/dist/service-template-data.d.ts +19 -0
  67. package/dist/service-template-data.d.ts.map +1 -0
  68. package/dist/service-template-data.js +278 -0
  69. package/dist/service-templates.d.ts +260 -0
  70. package/dist/service-templates.d.ts.map +1 -0
  71. package/dist/service-templates.js +293 -0
  72. package/dist/service.d.ts +273 -0
  73. package/dist/service.d.ts.map +1 -0
  74. package/dist/service.js +51 -0
  75. package/dist/task-runtime.d.ts +19 -0
  76. package/dist/task-runtime.d.ts.map +1 -0
  77. package/dist/task-runtime.js +11 -0
  78. package/dist/tasks/deliver-reminder.d.ts +9 -0
  79. package/dist/tasks/deliver-reminder.d.ts.map +1 -0
  80. package/dist/tasks/deliver-reminder.js +12 -0
  81. package/dist/tasks/index.d.ts +3 -0
  82. package/dist/tasks/index.d.ts.map +1 -0
  83. package/dist/tasks/index.js +2 -0
  84. package/dist/tasks/send-due-reminders.d.ts +7 -0
  85. package/dist/tasks/send-due-reminders.d.ts.map +1 -0
  86. package/dist/tasks/send-due-reminders.js +31 -0
  87. package/dist/template-authoring.d.ts +23 -0
  88. package/dist/template-authoring.d.ts.map +1 -0
  89. package/dist/template-authoring.js +386 -0
  90. package/dist/types.d.ts +82 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +1 -0
  93. package/dist/validation.d.ts +1093 -0
  94. package/dist/validation.d.ts.map +1 -0
  95. package/dist/validation.js +451 -0
  96. package/package.json +102 -0
@@ -0,0 +1,310 @@
1
+ import { bookings, bookingTravelers } from "@voyant-travel/bookings/schema";
2
+ import { bookingPaymentSchedules, invoices } from "@voyant-travel/finance";
3
+ import { desc, eq } from "drizzle-orm";
4
+ import { notificationReminderRuns, } from "./schema.js";
5
+ import { sendInvoiceNotification, sendNotification } from "./service-deliveries.js";
6
+ import { getPaymentReminderBookingStatusSkipReason, OPEN_PAYMENT_SCHEDULE_STATUSES, paymentScheduleStatusSkipReason, } from "./service-reminder-booking-context.js";
7
+ import { buildReminderQueueSummary, buildReminderSweepSummary, markReminderRunFailed, markReminderRunSent, markReminderRunSkipped, } from "./service-reminder-run-state.js";
8
+ import { applyQuietHours, evaluateStage, exceedsRecipientRateLimit, fetchTargetsForRule, getNotificationSettings, listActiveRulesByPriority, listChannelsForStage, listStagesForRule, loadHistory, suppressedByGroup, } from "./service-sequence.js";
9
+ import { resolveReminderRecipient, startOfUtcDay, toDateString, toTimestamp, } from "./service-shared.js";
10
+ function buildStageDedupeKey(ruleId, targetId, runDate, stageId, channel) {
11
+ return `${ruleId}:${targetId}:${runDate}:${stageId}:${channel}`;
12
+ }
13
+ async function fetchScheduleRow(db, scheduleId) {
14
+ const [row] = await db
15
+ .select()
16
+ .from(bookingPaymentSchedules)
17
+ .where(eq(bookingPaymentSchedules.id, scheduleId))
18
+ .limit(1);
19
+ return row ?? null;
20
+ }
21
+ async function fetchInvoiceRow(db, invoiceId) {
22
+ const [row] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
23
+ return row ?? null;
24
+ }
25
+ async function emitStageChannelRun(db, dispatcher, rule, stage, channelRow, target, recipient, scheduledAt, sendCountAtFire, enqueueDelivery, now) {
26
+ const runDate = toDateString(startOfUtcDay(scheduledAt));
27
+ const dedupeKey = buildStageDedupeKey(rule.id, target.id, runDate, stage.id, channelRow.channel);
28
+ const [existingRun] = await db
29
+ .select()
30
+ .from(notificationReminderRuns)
31
+ .where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
32
+ .limit(1);
33
+ if (existingRun) {
34
+ return { status: "skipped", runId: existingRun.id };
35
+ }
36
+ const baseValues = {
37
+ reminderRuleId: rule.id,
38
+ targetType: rule.targetType,
39
+ targetId: target.id,
40
+ dedupeKey,
41
+ bookingId: target.bookingId,
42
+ personId: null,
43
+ organizationId: null,
44
+ paymentSessionId: null,
45
+ notificationDeliveryId: null,
46
+ recipient: recipient?.email ?? null,
47
+ scheduledFor: scheduledAt,
48
+ processedAt: now,
49
+ errorMessage: null,
50
+ metadata: {
51
+ stageId: stage.id,
52
+ stageOrderIndex: stage.orderIndex,
53
+ stageChannelId: channelRow.id,
54
+ channel: channelRow.channel,
55
+ anchor: stage.anchor,
56
+ sendCountAtFire,
57
+ ruleSlug: rule.slug,
58
+ },
59
+ };
60
+ if (!recipient?.email) {
61
+ const [run] = await db
62
+ .insert(notificationReminderRuns)
63
+ .values({ ...baseValues, status: "skipped", errorMessage: "no_recipient" })
64
+ .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
65
+ .returning();
66
+ return { status: "skipped", runId: run?.id ?? null };
67
+ }
68
+ if (enqueueDelivery && !dispatcher) {
69
+ const [queuedRun] = await db
70
+ .insert(notificationReminderRuns)
71
+ .values({ ...baseValues, status: "queued" })
72
+ .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
73
+ .returning();
74
+ if (!queuedRun)
75
+ return { status: "skipped", runId: null };
76
+ try {
77
+ await enqueueDelivery({ reminderRunId: queuedRun.id });
78
+ return { status: "queued", runId: queuedRun.id };
79
+ }
80
+ catch (error) {
81
+ const message = error instanceof Error ? error.message : "enqueue_failed";
82
+ const failed = await markReminderRunFailed(db, queuedRun.id, new Date(), message);
83
+ return { status: "failed", runId: failed?.id ?? null };
84
+ }
85
+ }
86
+ if (!dispatcher) {
87
+ return { status: "skipped", runId: null };
88
+ }
89
+ const [processingRun] = await db
90
+ .insert(notificationReminderRuns)
91
+ .values({ ...baseValues, status: "processing" })
92
+ .onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
93
+ .returning();
94
+ if (!processingRun) {
95
+ return { status: "skipped", runId: null };
96
+ }
97
+ try {
98
+ const data = {
99
+ reminderRuleId: rule.id,
100
+ reminderRunId: processingRun.id,
101
+ stageId: stage.id,
102
+ stageOrderIndex: stage.orderIndex,
103
+ sendCountAtFire,
104
+ };
105
+ let delivery = null;
106
+ if (rule.targetType === "invoice") {
107
+ const invoice = await fetchInvoiceRow(db, target.id);
108
+ if (!invoice) {
109
+ return {
110
+ status: "skipped",
111
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), "invoice_not_found"))
112
+ ?.id ?? null,
113
+ };
114
+ }
115
+ delivery = await sendInvoiceNotification(db, dispatcher, invoice.id, {
116
+ templateId: channelRow.templateId ?? null,
117
+ templateSlug: channelRow.templateSlug ?? null,
118
+ channel: channelRow.channel,
119
+ provider: channelRow.provider ?? null,
120
+ to: recipient.email,
121
+ data,
122
+ metadata: { reminderRuleId: rule.id, reminderRunId: processingRun.id, stageId: stage.id },
123
+ scheduledFor: scheduledAt.toISOString(),
124
+ });
125
+ }
126
+ else if (rule.targetType === "booking_payment_schedule") {
127
+ const schedule = await fetchScheduleRow(db, target.id);
128
+ if (!schedule) {
129
+ return {
130
+ status: "skipped",
131
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), "schedule_not_found"))
132
+ ?.id ?? null,
133
+ };
134
+ }
135
+ if (!OPEN_PAYMENT_SCHEDULE_STATUSES.has(schedule.status)) {
136
+ return {
137
+ status: "skipped",
138
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), paymentScheduleStatusSkipReason(schedule.status)))?.id ?? null,
139
+ };
140
+ }
141
+ const bookingSkipReason = await getPaymentReminderBookingStatusSkipReason(db, schedule.bookingId);
142
+ if (bookingSkipReason) {
143
+ return {
144
+ status: "skipped",
145
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), bookingSkipReason))
146
+ ?.id ?? null,
147
+ };
148
+ }
149
+ delivery = await sendNotification(db, dispatcher, {
150
+ templateId: channelRow.templateId ?? null,
151
+ templateSlug: channelRow.templateSlug ?? null,
152
+ channel: channelRow.channel,
153
+ provider: channelRow.provider ?? null,
154
+ to: recipient.email,
155
+ data: {
156
+ ...data,
157
+ bookingId: schedule.bookingId,
158
+ dueDate: schedule.dueDate,
159
+ amountCents: schedule.amountCents,
160
+ currency: schedule.currency,
161
+ },
162
+ targetType: "booking_payment_schedule",
163
+ targetId: schedule.id,
164
+ bookingId: schedule.bookingId,
165
+ metadata: { reminderRuleId: rule.id, reminderRunId: processingRun.id, stageId: stage.id },
166
+ scheduledFor: scheduledAt.toISOString(),
167
+ });
168
+ }
169
+ else {
170
+ return {
171
+ status: "skipped",
172
+ runId: (await markReminderRunSkipped(db, processingRun.id, new Date(), "unsupported_target_type"))?.id ?? null,
173
+ };
174
+ }
175
+ const sent = await markReminderRunSent(db, processingRun.id, new Date(), delivery?.id ?? null);
176
+ return { status: "sent", runId: sent?.id ?? null };
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof Error ? error.message : "delivery_failed";
180
+ const failed = await markReminderRunFailed(db, processingRun.id, new Date(), message);
181
+ return { status: "failed", runId: failed?.id ?? null };
182
+ }
183
+ }
184
+ async function processStageRuleTargets(db, options) {
185
+ const tally = { processed: 0, sent: 0, queued: 0, skipped: 0, failed: 0 };
186
+ for (const target of options.targets) {
187
+ const history = await loadHistory(db, options.rule.id, target.id);
188
+ const decision = evaluateStage(options.rule, options.stages, target, history, options.today);
189
+ if (!decision.fire)
190
+ continue;
191
+ const channels = await listChannelsForStage(db, decision.stage.id);
192
+ if (channels.length === 0)
193
+ continue;
194
+ const booking = target.bookingId
195
+ ? ((await db
196
+ .select({
197
+ id: bookings.id,
198
+ bookingNumber: bookings.bookingNumber,
199
+ personId: bookings.personId,
200
+ organizationId: bookings.organizationId,
201
+ contactFirstName: bookings.contactFirstName,
202
+ contactLastName: bookings.contactLastName,
203
+ contactEmail: bookings.contactEmail,
204
+ contactPhone: bookings.contactPhone,
205
+ contactPreferredLanguage: bookings.contactPreferredLanguage,
206
+ })
207
+ .from(bookings)
208
+ .where(eq(bookings.id, target.bookingId))
209
+ .limit(1))[0] ?? null)
210
+ : null;
211
+ const participants = booking
212
+ ? await db
213
+ .select({
214
+ id: bookingTravelers.id,
215
+ firstName: bookingTravelers.firstName,
216
+ lastName: bookingTravelers.lastName,
217
+ email: bookingTravelers.email,
218
+ participantType: bookingTravelers.participantType,
219
+ isPrimary: bookingTravelers.isPrimary,
220
+ })
221
+ .from(bookingTravelers)
222
+ .where(eq(bookingTravelers.bookingId, booking.id))
223
+ .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt)
224
+ : [];
225
+ const recipient = booking ? resolveReminderRecipient(booking, participants) : null;
226
+ if (await suppressedByGroup(db, recipient?.email ?? null, options.rule.suppressionGroup, options.settings, options.now)) {
227
+ tally.skipped += 1;
228
+ continue;
229
+ }
230
+ const { scheduledAt } = applyQuietHours(options.now, decision.stage, options.settings);
231
+ for (const channelRow of channels) {
232
+ if (recipient?.email &&
233
+ (await exceedsRecipientRateLimit(db, recipient.email, channelRow.channel, options.settings, options.now))) {
234
+ tally.skipped += 1;
235
+ continue;
236
+ }
237
+ const result = await emitStageChannelRun(db, options.dispatcher, options.rule, decision.stage, channelRow, target, recipient ?? null, scheduledAt, decision.sendCountAtFire, options.enqueueDelivery, options.now);
238
+ tally.processed += 1;
239
+ if (result.status === "sent")
240
+ tally.sent += 1;
241
+ if (result.status === "queued")
242
+ tally.queued += 1;
243
+ if (result.status === "skipped")
244
+ tally.skipped += 1;
245
+ if (result.status === "failed")
246
+ tally.failed += 1;
247
+ }
248
+ }
249
+ return tally;
250
+ }
251
+ export async function runStageBasedDueReminders(db, dispatcher, input = {}) {
252
+ const now = toTimestamp(input.now) ?? new Date();
253
+ const today = startOfUtcDay(now);
254
+ const settings = await getNotificationSettings(db);
255
+ const rules = await listActiveRulesByPriority(db);
256
+ const summary = buildReminderSweepSummary();
257
+ for (const rule of rules) {
258
+ const stages = await listStagesForRule(db, rule.id);
259
+ if (stages.length === 0)
260
+ continue;
261
+ const targets = await fetchTargetsForRule(db, rule, stages, today);
262
+ if (targets.length === 0)
263
+ continue;
264
+ const tally = await processStageRuleTargets(db, {
265
+ rule,
266
+ stages,
267
+ targets,
268
+ settings,
269
+ today,
270
+ now,
271
+ dispatcher,
272
+ enqueueDelivery: null,
273
+ });
274
+ summary.processed += tally.processed;
275
+ summary.sent += tally.sent;
276
+ summary.skipped += tally.skipped;
277
+ summary.failed += tally.failed;
278
+ }
279
+ return summary;
280
+ }
281
+ export async function queueStageBasedDueReminders(db, enqueueDelivery, input = {}) {
282
+ const now = toTimestamp(input.now) ?? new Date();
283
+ const today = startOfUtcDay(now);
284
+ const settings = await getNotificationSettings(db);
285
+ const rules = await listActiveRulesByPriority(db);
286
+ const summary = buildReminderQueueSummary();
287
+ for (const rule of rules) {
288
+ const stages = await listStagesForRule(db, rule.id);
289
+ if (stages.length === 0)
290
+ continue;
291
+ const targets = await fetchTargetsForRule(db, rule, stages, today);
292
+ if (targets.length === 0)
293
+ continue;
294
+ const tally = await processStageRuleTargets(db, {
295
+ rule,
296
+ stages,
297
+ targets,
298
+ settings,
299
+ today,
300
+ now,
301
+ dispatcher: null,
302
+ enqueueDelivery,
303
+ });
304
+ summary.processed += tally.processed;
305
+ summary.queued += tally.queued;
306
+ summary.skipped += tally.skipped;
307
+ summary.failed += tally.failed;
308
+ }
309
+ return summary;
310
+ }
@@ -0,0 +1,30 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import { type ReminderDeliveryEnqueuer } from "./service-reminder-run-state.js";
3
+ import type { NotificationService, RunDueRemindersInput } from "./service-shared.js";
4
+ export { type BookingEventReminderRuntimeOptions, bookingIsPaidInFullForNotification, dispatchReminderEventRules, } from "./service-reminder-events.js";
5
+ export { queueStageBasedDueReminders, runStageBasedDueReminders, } from "./service-reminder-stage-runs.js";
6
+ export declare function queueDueReminders(db: PostgresJsDatabase, input: RunDueRemindersInput | undefined, enqueueDelivery: ReminderDeliveryEnqueuer): Promise<import("./service-shared.js").ReminderQueueResult>;
7
+ export declare function deliverReminderRun(db: PostgresJsDatabase, dispatcher: NotificationService, input: {
8
+ reminderRunId: string;
9
+ }): Promise<{
10
+ id: string;
11
+ reminderRuleId: string;
12
+ targetType: "invoice" | "booking_payment_schedule" | "booking_confirmed" | "payment_complete" | "booking_cancelled_non_payment";
13
+ targetId: string;
14
+ dedupeKey: string;
15
+ bookingId: string | null;
16
+ personId: string | null;
17
+ organizationId: string | null;
18
+ paymentSessionId: string | null;
19
+ notificationDeliveryId: string | null;
20
+ status: "sent" | "failed" | "queued" | "processing" | "skipped";
21
+ recipient: string | null;
22
+ scheduledFor: Date;
23
+ processedAt: Date;
24
+ errorMessage: string | null;
25
+ metadata: Record<string, unknown> | null;
26
+ createdAt: Date;
27
+ updatedAt: Date;
28
+ } | null>;
29
+ export declare function runDueReminders(db: PostgresJsDatabase, dispatcher: NotificationService, input?: RunDueRemindersInput): Promise<import("./service-shared.js").ReminderSweepResult>;
30
+ //# sourceMappingURL=service-reminders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-reminders.d.ts","sourceRoot":"","sources":["../src/service-reminders.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAYjE,OAAO,EAQL,KAAK,wBAAwB,EAE9B,MAAM,iCAAiC,CAAA;AAKxC,OAAO,KAAK,EAEV,mBAAmB,EACnB,oBAAoB,EACrB,MAAM,qBAAqB,CAAA;AAG5B,OAAO,EACL,KAAK,kCAAkC,EACvC,kCAAkC,EAClC,0BAA0B,GAC3B,MAAM,8BAA8B,CAAA;AACrC,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,GAC1B,MAAM,kCAAkC,CAAA;AA4KzC,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,oBAAoB,YAAK,EAChC,eAAe,EAAE,wBAAwB,8DAG1C;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,kBAAkB,EACtB,UAAU,EAAE,mBAAmB,EAC/B,KAAK,EAAE;IAAE,aAAa,EAAE,MAAM,CAAA;CAAE;;;;;;;;;;;;;;;;;;;UAwDjC;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,kBAAkB,EACtB,UAAU,EAAE,mBAAmB,EAC/B,KAAK,GAAE,oBAAyB,8DAGjC"}
@@ -0,0 +1,189 @@
1
+ import { bookings, bookingTravelers } from "@voyant-travel/bookings/schema";
2
+ import { bookingPaymentSchedules } from "@voyant-travel/finance";
3
+ import { and, desc, eq } from "drizzle-orm";
4
+ import { notificationReminderRuns } from "./schema.js";
5
+ import { sendInvoiceNotification, sendNotification } from "./service-deliveries.js";
6
+ import { bookingStatusSkipReason, getBookingPaymentNotificationContext, OPEN_PAYMENT_SCHEDULE_STATUSES, PAYABLE_BOOKING_STATUSES, paymentScheduleStatusSkipReason, serializeBookingPaymentContext, } from "./service-reminder-booking-context.js";
7
+ import { getReminderRuleById, getReminderRunById, markReminderRunFailed, markReminderRunSent, markReminderRunSkipped, resolveChannelOverride, } from "./service-reminder-run-state.js";
8
+ import { queueStageBasedDueReminders, runStageBasedDueReminders, } from "./service-reminder-stage-runs.js";
9
+ import { listBookingNotificationItems, resolveReminderRecipient } from "./service-shared.js";
10
+ export { bookingIsPaidInFullForNotification, dispatchReminderEventRules, } from "./service-reminder-events.js";
11
+ export { queueStageBasedDueReminders, runStageBasedDueReminders, } from "./service-reminder-stage-runs.js";
12
+ async function sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rule, now, channelOverride) {
13
+ const [schedule] = await db
14
+ .select()
15
+ .from(bookingPaymentSchedules)
16
+ .where(eq(bookingPaymentSchedules.id, run.targetId))
17
+ .limit(1);
18
+ if (!schedule) {
19
+ return markReminderRunSkipped(db, run.id, now, "Booking payment schedule not found for reminder run");
20
+ }
21
+ if (!OPEN_PAYMENT_SCHEDULE_STATUSES.has(schedule.status)) {
22
+ return markReminderRunSkipped(db, run.id, now, paymentScheduleStatusSkipReason(schedule.status));
23
+ }
24
+ const [booking] = await db
25
+ .select({
26
+ id: bookings.id,
27
+ bookingNumber: bookings.bookingNumber,
28
+ status: bookings.status,
29
+ personId: bookings.personId,
30
+ organizationId: bookings.organizationId,
31
+ contactFirstName: bookings.contactFirstName,
32
+ contactLastName: bookings.contactLastName,
33
+ contactEmail: bookings.contactEmail,
34
+ contactPhone: bookings.contactPhone,
35
+ contactPreferredLanguage: bookings.contactPreferredLanguage,
36
+ sellCurrency: bookings.sellCurrency,
37
+ sellAmountCents: bookings.sellAmountCents,
38
+ startDate: bookings.startDate,
39
+ endDate: bookings.endDate,
40
+ })
41
+ .from(bookings)
42
+ .where(eq(bookings.id, schedule.bookingId))
43
+ .limit(1);
44
+ if (!booking) {
45
+ return markReminderRunSkipped(db, run.id, now, "Booking not found for payment schedule");
46
+ }
47
+ if (!PAYABLE_BOOKING_STATUSES.has(booking.status)) {
48
+ return markReminderRunSkipped(db, run.id, now, bookingStatusSkipReason(booking.status));
49
+ }
50
+ const [participants, items, paymentContext] = await Promise.all([
51
+ db
52
+ .select({
53
+ id: bookingTravelers.id,
54
+ firstName: bookingTravelers.firstName,
55
+ lastName: bookingTravelers.lastName,
56
+ email: bookingTravelers.email,
57
+ participantType: bookingTravelers.participantType,
58
+ isPrimary: bookingTravelers.isPrimary,
59
+ })
60
+ .from(bookingTravelers)
61
+ .where(eq(bookingTravelers.bookingId, booking.id))
62
+ .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt),
63
+ listBookingNotificationItems(db, booking.id),
64
+ getBookingPaymentNotificationContext(db, booking.id),
65
+ ]);
66
+ const fallbackRecipient = resolveReminderRecipient(booking, participants);
67
+ const traveler = participants.find((entry) => entry.email === run.recipient) ?? fallbackRecipient ?? null;
68
+ const recipientEmail = run.recipient ?? traveler?.email ?? null;
69
+ if (!recipientEmail) {
70
+ return markReminderRunSkipped(db, run.id, now, "No traveler email available for booking payment reminder");
71
+ }
72
+ try {
73
+ const delivery = await sendNotification(db, dispatcher, {
74
+ templateId: channelOverride.templateId,
75
+ templateSlug: channelOverride.templateSlug,
76
+ channel: channelOverride.channel,
77
+ provider: channelOverride.provider,
78
+ to: recipientEmail,
79
+ data: {
80
+ bookingId: booking.id,
81
+ bookingNumber: booking.bookingNumber,
82
+ dueDate: schedule.dueDate,
83
+ amountCents: schedule.amountCents,
84
+ currency: schedule.currency,
85
+ scheduleType: schedule.scheduleType,
86
+ traveler: traveler
87
+ ? {
88
+ firstName: traveler.firstName,
89
+ lastName: traveler.lastName,
90
+ email: recipientEmail,
91
+ participantType: traveler.participantType,
92
+ isPrimary: traveler.isPrimary,
93
+ }
94
+ : null,
95
+ travelers: participants,
96
+ booking: {
97
+ id: booking.id,
98
+ bookingNumber: booking.bookingNumber,
99
+ startDate: booking.startDate,
100
+ endDate: booking.endDate,
101
+ sellCurrency: booking.sellCurrency,
102
+ sellAmountCents: booking.sellAmountCents,
103
+ },
104
+ ...serializeBookingPaymentContext(paymentContext, schedule),
105
+ items,
106
+ },
107
+ targetType: "booking_payment_schedule",
108
+ targetId: schedule.id,
109
+ bookingId: booking.id,
110
+ personId: booking.personId ?? null,
111
+ organizationId: booking.organizationId ?? null,
112
+ metadata: {
113
+ reminderRuleId: rule.id,
114
+ reminderRunId: run.id,
115
+ },
116
+ scheduledFor: run.scheduledFor.toISOString(),
117
+ });
118
+ return markReminderRunSent(db, run.id, new Date(), delivery?.id ?? null);
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : "Notification reminder failed";
122
+ return markReminderRunFailed(db, run.id, new Date(), message);
123
+ }
124
+ }
125
+ async function sendQueuedInvoiceReminder(db, dispatcher, run, rule, now, channelOverride) {
126
+ const delivery = await sendInvoiceNotification(db, dispatcher, run.targetId, {
127
+ templateId: channelOverride.templateId,
128
+ templateSlug: channelOverride.templateSlug,
129
+ channel: channelOverride.channel,
130
+ provider: channelOverride.provider,
131
+ to: run.recipient ?? undefined,
132
+ data: {
133
+ reminderRunId: run.id,
134
+ },
135
+ metadata: {
136
+ reminderRuleId: rule.id,
137
+ reminderRunId: run.id,
138
+ },
139
+ scheduledFor: run.scheduledFor.toISOString(),
140
+ });
141
+ if (!delivery) {
142
+ return markReminderRunSkipped(db, run.id, now, "Invoice not found for reminder run");
143
+ }
144
+ return markReminderRunSent(db, run.id, new Date(), delivery.id ?? null);
145
+ }
146
+ export async function queueDueReminders(db, input = {}, enqueueDelivery) {
147
+ return queueStageBasedDueReminders(db, enqueueDelivery, input);
148
+ }
149
+ export async function deliverReminderRun(db, dispatcher, input) {
150
+ const now = new Date();
151
+ const [claimedRun] = await db
152
+ .update(notificationReminderRuns)
153
+ .set({
154
+ status: "processing",
155
+ errorMessage: null,
156
+ processedAt: now,
157
+ updatedAt: now,
158
+ })
159
+ .where(and(eq(notificationReminderRuns.id, input.reminderRunId), eq(notificationReminderRuns.status, "queued")))
160
+ .returning();
161
+ const run = claimedRun ?? (await getReminderRunById(db, input.reminderRunId));
162
+ if (!run) {
163
+ return null;
164
+ }
165
+ if (!claimedRun) {
166
+ return run;
167
+ }
168
+ const rule = await getReminderRuleById(db, run.reminderRuleId);
169
+ if (!rule) {
170
+ return markReminderRunFailed(db, run.id, new Date(), "Reminder rule not found");
171
+ }
172
+ const channelOverride = await resolveChannelOverride(db, run, rule);
173
+ try {
174
+ if (run.targetType === "booking_payment_schedule") {
175
+ return await sendQueuedBookingPaymentScheduleReminder(db, dispatcher, run, rule, now, channelOverride);
176
+ }
177
+ if (run.targetType === "invoice") {
178
+ return await sendQueuedInvoiceReminder(db, dispatcher, run, rule, now, channelOverride);
179
+ }
180
+ return markReminderRunSkipped(db, run.id, now, "Unsupported reminder target type");
181
+ }
182
+ catch (error) {
183
+ const message = error instanceof Error ? error.message : "Reminder delivery failed";
184
+ return markReminderRunFailed(db, run.id, new Date(), message);
185
+ }
186
+ }
187
+ export async function runDueReminders(db, dispatcher, input = {}) {
188
+ return runStageBasedDueReminders(db, dispatcher, input);
189
+ }
@@ -0,0 +1,50 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import type { NotificationReminderRule, NotificationReminderRuleStage } from "./schema.js";
3
+ import type { ReminderTargetSnapshot } from "./service-sequence.js";
4
+ /**
5
+ * Computes the date range a target's `due_date` (or `issue_date`) needs to
6
+ * fall in for any of the rule's stages to be inside their eligibility window
7
+ * today.
8
+ *
9
+ * From `inWindow`: today must satisfy
10
+ * anchor + windowStartDays ≤ today ≤ anchor + windowEndDays
11
+ * Solving for anchor:
12
+ * today − windowEndDays ≤ anchor ≤ today − windowStartDays
13
+ *
14
+ * Across all stages with anchor=`due_date`, we union the [start, end] ranges
15
+ * and use the resulting envelope as a SQL `BETWEEN` filter. Returns null when
16
+ * no stage is anchored on the requested column (e.g. all stages anchor on
17
+ * `departure_date`) — caller should skip the pushdown in that case.
18
+ */
19
+ export declare function computeAnchorDateEnvelope(stages: NotificationReminderRuleStage[], today: Date, anchor: NotificationReminderRuleStage["anchor"]): {
20
+ from: string;
21
+ to: string;
22
+ } | null;
23
+ export type DateEnvelopes = {
24
+ /** When set, only fetch payment schedules whose `due_date` falls in this range. */
25
+ paymentScheduleDueDate?: {
26
+ from: string;
27
+ to: string;
28
+ };
29
+ /** When set, only fetch invoices whose `due_date` falls in this range. */
30
+ invoiceDueDate?: {
31
+ from: string;
32
+ to: string;
33
+ };
34
+ /** When set, only fetch invoices whose `issue_date` falls in this range. */
35
+ invoiceIssueDate?: {
36
+ from: string;
37
+ to: string;
38
+ };
39
+ };
40
+ export declare function fetchOpenPaymentScheduleTargets(db: PostgresJsDatabase, envelopes?: DateEnvelopes): Promise<ReminderTargetSnapshot[]>;
41
+ export declare function fetchOpenInvoiceTargets(db: PostgresJsDatabase, envelopes?: DateEnvelopes): Promise<ReminderTargetSnapshot[]>;
42
+ /**
43
+ * Per-rule target fetch that pushes a date envelope into the WHERE when all
44
+ * relevant stages share an anchor we can SQL-filter on (`due_date` for both
45
+ * target types, `invoice_issued_at` for invoices). Other anchors fall through
46
+ * to the unfiltered fetch — they're expected to be rare and the in-app
47
+ * window check still rejects misses.
48
+ */
49
+ export declare function fetchTargetsForRule(db: PostgresJsDatabase, rule: NotificationReminderRule, stages?: NotificationReminderRuleStage[], today?: Date): Promise<ReminderTargetSnapshot[]>;
50
+ //# sourceMappingURL=service-sequence-targets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-sequence-targets.d.ts","sourceRoot":"","sources":["../src/service-sequence-targets.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,KAAK,EAAE,wBAAwB,EAAE,6BAA6B,EAAE,MAAM,aAAa,CAAA;AAC1F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAGnE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,6BAA6B,EAAE,EACvC,KAAK,EAAE,IAAI,EACX,MAAM,EAAE,6BAA6B,CAAC,QAAQ,CAAC,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAgBrC;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,mFAAmF;IACnF,sBAAsB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;IACrD,0EAA0E;IAC1E,cAAc,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7C,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;CAChD,CAAA;AASD,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,kBAAkB,EACtB,SAAS,GAAE,aAAkB,GAC5B,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAiCnC;AAED,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,kBAAkB,EACtB,SAAS,GAAE,aAAkB,GAC5B,OAAO,CAAC,sBAAsB,EAAE,CAAC,CA+CnC;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,wBAAwB,EAC9B,MAAM,GAAE,6BAA6B,EAAO,EAC5C,KAAK,GAAE,IAAiB,GACvB,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAcnC"}