@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.
- package/LICENSE +201 -0
- package/README.md +179 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +196 -0
- package/dist/liquid.d.ts +5 -0
- package/dist/liquid.d.ts.map +1 -0
- package/dist/liquid.js +156 -0
- package/dist/providers/local.d.ts +22 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +23 -0
- package/dist/providers/voyant-cloud-email.d.ts +27 -0
- package/dist/providers/voyant-cloud-email.d.ts.map +1 -0
- package/dist/providers/voyant-cloud-email.js +73 -0
- package/dist/providers/voyant-cloud-sms.d.ts +26 -0
- package/dist/providers/voyant-cloud-sms.d.ts.map +1 -0
- package/dist/providers/voyant-cloud-sms.js +24 -0
- package/dist/routes.d.ts +1546 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +337 -0
- package/dist/schema.d.ts +2119 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +356 -0
- package/dist/service-booking-document-lifecycle.d.ts +99 -0
- package/dist/service-booking-document-lifecycle.d.ts.map +1 -0
- package/dist/service-booking-document-lifecycle.js +259 -0
- package/dist/service-booking-documents.d.ts +256 -0
- package/dist/service-booking-documents.d.ts.map +1 -0
- package/dist/service-booking-documents.js +323 -0
- package/dist/service-deliveries.d.ts +183 -0
- package/dist/service-deliveries.d.ts.map +1 -0
- package/dist/service-deliveries.js +413 -0
- package/dist/service-delivery-metadata.d.ts +42 -0
- package/dist/service-delivery-metadata.d.ts.map +1 -0
- package/dist/service-delivery-metadata.js +114 -0
- package/dist/service-reminder-authoring.d.ts +33 -0
- package/dist/service-reminder-authoring.d.ts.map +1 -0
- package/dist/service-reminder-authoring.js +247 -0
- package/dist/service-reminder-booking-context.d.ts +94 -0
- package/dist/service-reminder-booking-context.d.ts.map +1 -0
- package/dist/service-reminder-booking-context.js +164 -0
- package/dist/service-reminder-events.d.ts +33 -0
- package/dist/service-reminder-events.d.ts.map +1 -0
- package/dist/service-reminder-events.js +178 -0
- package/dist/service-reminder-run-state.d.ts +114 -0
- package/dist/service-reminder-run-state.d.ts.map +1 -0
- package/dist/service-reminder-run-state.js +100 -0
- package/dist/service-reminder-stage-runs.d.ts +6 -0
- package/dist/service-reminder-stage-runs.d.ts.map +1 -0
- package/dist/service-reminder-stage-runs.js +310 -0
- package/dist/service-reminders.d.ts +30 -0
- package/dist/service-reminders.d.ts.map +1 -0
- package/dist/service-reminders.js +189 -0
- package/dist/service-sequence-targets.d.ts +50 -0
- package/dist/service-sequence-targets.d.ts.map +1 -0
- package/dist/service-sequence-targets.js +136 -0
- package/dist/service-sequence.d.ts +68 -0
- package/dist/service-sequence.d.ts.map +1 -0
- package/dist/service-sequence.js +316 -0
- package/dist/service-shared.d.ts +107 -0
- package/dist/service-shared.d.ts.map +1 -0
- package/dist/service-shared.js +159 -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-template-data.d.ts +19 -0
- package/dist/service-template-data.d.ts.map +1 -0
- package/dist/service-template-data.js +278 -0
- package/dist/service-templates.d.ts +260 -0
- package/dist/service-templates.d.ts.map +1 -0
- package/dist/service-templates.js +293 -0
- package/dist/service.d.ts +273 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +51 -0
- package/dist/task-runtime.d.ts +19 -0
- package/dist/task-runtime.d.ts.map +1 -0
- package/dist/task-runtime.js +11 -0
- package/dist/tasks/deliver-reminder.d.ts +9 -0
- package/dist/tasks/deliver-reminder.d.ts.map +1 -0
- package/dist/tasks/deliver-reminder.js +12 -0
- package/dist/tasks/index.d.ts +3 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +2 -0
- package/dist/tasks/send-due-reminders.d.ts +7 -0
- package/dist/tasks/send-due-reminders.d.ts.map +1 -0
- package/dist/tasks/send-due-reminders.js +31 -0
- package/dist/template-authoring.d.ts +23 -0
- package/dist/template-authoring.d.ts.map +1 -0
- package/dist/template-authoring.js +386 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +1093 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +451 -0
- 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"}
|