@voyantjs/notifications 0.1.1 → 0.3.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/providers/local.d.ts.map +1 -1
- package/dist/providers/local.js +0 -1
- package/dist/service-deliveries.d.ts +153 -0
- package/dist/service-deliveries.d.ts.map +1 -0
- package/dist/service-deliveries.js +334 -0
- package/dist/service-reminders.d.ts +4 -0
- package/dist/service-reminders.d.ts.map +1 -0
- package/dist/service-reminders.js +215 -0
- package/dist/service-shared.d.ts +73 -0
- package/dist/service-shared.d.ts.map +1 -0
- package/dist/service-shared.js +124 -0
- package/dist/service-templates.d.ts +182 -0
- package/dist/service-templates.d.ts.map +1 -0
- package/dist/service-templates.js +115 -0
- package/dist/service.d.ts +21 -367
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +20 -773
- package/dist/tasks/send-due-reminders.d.ts +1 -6
- package/dist/tasks/send-due-reminders.d.ts.map +1 -1
- package/package.json +6 -6
package/dist/service.js
CHANGED
|
@@ -1,775 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
/**
|
|
6
|
-
* Thrown when `send()` is called with a channel that has no registered
|
|
7
|
-
* provider, or with a provider name that does not exist.
|
|
8
|
-
*/
|
|
9
|
-
export class NotificationError extends Error {
|
|
10
|
-
constructor(message) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.name = "NotificationError";
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
export function createNotificationService(providers) {
|
|
16
|
-
const byChannel = new Map();
|
|
17
|
-
const byName = new Map();
|
|
18
|
-
for (const provider of providers) {
|
|
19
|
-
byName.set(provider.name, provider);
|
|
20
|
-
for (const channel of provider.channels) {
|
|
21
|
-
byChannel.set(channel, provider);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return {
|
|
25
|
-
async send(payload) {
|
|
26
|
-
const hintedProvider = payload.provider ? byName.get(payload.provider) : null;
|
|
27
|
-
const provider = hintedProvider ?? byChannel.get(payload.channel);
|
|
28
|
-
if (!provider) {
|
|
29
|
-
throw new NotificationError(`No notification provider registered for channel "${payload.channel}"`);
|
|
30
|
-
}
|
|
31
|
-
return provider.send(payload);
|
|
32
|
-
},
|
|
33
|
-
async sendWith(providerName, payload) {
|
|
34
|
-
const provider = byName.get(providerName);
|
|
35
|
-
if (!provider) {
|
|
36
|
-
throw new NotificationError(`No notification provider registered with name "${providerName}"`);
|
|
37
|
-
}
|
|
38
|
-
return provider.send(payload);
|
|
39
|
-
},
|
|
40
|
-
getProvider(channel) {
|
|
41
|
-
return byChannel.get(channel);
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
function resolveMustachePath(path, scope) {
|
|
46
|
-
const parts = path.match(/[^.[\]]+/g) ?? [];
|
|
47
|
-
let current = scope;
|
|
48
|
-
for (const part of parts) {
|
|
49
|
-
if (current == null || typeof current !== "object")
|
|
50
|
-
return undefined;
|
|
51
|
-
current = current[part];
|
|
52
|
-
}
|
|
53
|
-
return current;
|
|
54
|
-
}
|
|
55
|
-
function stringifyRenderedValue(value) {
|
|
56
|
-
if (value == null)
|
|
57
|
-
return "";
|
|
58
|
-
if (typeof value === "string")
|
|
59
|
-
return value;
|
|
60
|
-
if (typeof value === "number" || typeof value === "boolean")
|
|
61
|
-
return String(value);
|
|
62
|
-
return JSON.stringify(value);
|
|
63
|
-
}
|
|
64
|
-
export function renderNotificationTemplate(template, data) {
|
|
65
|
-
if (!template)
|
|
66
|
-
return null;
|
|
67
|
-
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, path) => {
|
|
68
|
-
return stringifyRenderedValue(resolveMustachePath(path.trim(), data));
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
function toTimestamp(value) {
|
|
72
|
-
return value ? new Date(value) : null;
|
|
73
|
-
}
|
|
74
|
-
function startOfUtcDay(value) {
|
|
75
|
-
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
|
|
76
|
-
}
|
|
77
|
-
function addUtcDays(value, days) {
|
|
78
|
-
return new Date(value.getTime() + days * 24 * 60 * 60 * 1000);
|
|
79
|
-
}
|
|
80
|
-
function toDateString(value) {
|
|
81
|
-
return value.toISOString().slice(0, 10);
|
|
82
|
-
}
|
|
83
|
-
function buildReminderDedupeKey(ruleId, targetId, runDate) {
|
|
84
|
-
return `${ruleId}:${targetId}:${runDate}`;
|
|
85
|
-
}
|
|
86
|
-
function resolveReminderRecipient(participants) {
|
|
87
|
-
const withEmail = participants.filter((participant) => participant.email);
|
|
88
|
-
if (withEmail.length === 0) {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
const primary = withEmail.find((participant) => participant.isPrimary);
|
|
92
|
-
if (primary) {
|
|
93
|
-
return primary;
|
|
94
|
-
}
|
|
95
|
-
const preferredTypes = ["booker", "contact", "traveler", "occupant"];
|
|
96
|
-
for (const type of preferredTypes) {
|
|
97
|
-
const match = withEmail.find((participant) => participant.participantType === type);
|
|
98
|
-
if (match) {
|
|
99
|
-
return match;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return withEmail[0] ?? null;
|
|
103
|
-
}
|
|
104
|
-
async function listBookingNotificationParticipants(db, bookingId) {
|
|
105
|
-
return db
|
|
106
|
-
.select({
|
|
107
|
-
id: bookingParticipants.id,
|
|
108
|
-
firstName: bookingParticipants.firstName,
|
|
109
|
-
lastName: bookingParticipants.lastName,
|
|
110
|
-
email: bookingParticipants.email,
|
|
111
|
-
participantType: bookingParticipants.participantType,
|
|
112
|
-
isPrimary: bookingParticipants.isPrimary,
|
|
113
|
-
})
|
|
114
|
-
.from(bookingParticipants)
|
|
115
|
-
.where(eq(bookingParticipants.bookingId, bookingId))
|
|
116
|
-
.orderBy(desc(bookingParticipants.isPrimary), bookingParticipants.createdAt);
|
|
117
|
-
}
|
|
118
|
-
async function paginate(rowsPromise, totalPromise, limit, offset) {
|
|
119
|
-
const [data, totalRows] = await Promise.all([rowsPromise, totalPromise]);
|
|
120
|
-
return {
|
|
121
|
-
data,
|
|
122
|
-
total: totalRows[0]?.total ?? 0,
|
|
123
|
-
limit,
|
|
124
|
-
offset,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
async function sendBookingPaymentScheduleReminder(db, dispatcher, rule, schedule, now) {
|
|
128
|
-
const runDate = toDateString(startOfUtcDay(now));
|
|
129
|
-
const dedupeKey = buildReminderDedupeKey(rule.id, schedule.id, runDate);
|
|
130
|
-
const [existingRun] = await db
|
|
131
|
-
.select({ id: notificationReminderRuns.id })
|
|
132
|
-
.from(notificationReminderRuns)
|
|
133
|
-
.where(eq(notificationReminderRuns.dedupeKey, dedupeKey))
|
|
134
|
-
.limit(1);
|
|
135
|
-
if (existingRun) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
const [booking] = await db
|
|
139
|
-
.select()
|
|
140
|
-
.from(bookings)
|
|
141
|
-
.where(eq(bookings.id, schedule.bookingId))
|
|
142
|
-
.limit(1);
|
|
143
|
-
if (!booking) {
|
|
144
|
-
const [run] = await db
|
|
145
|
-
.insert(notificationReminderRuns)
|
|
146
|
-
.values({
|
|
147
|
-
reminderRuleId: rule.id,
|
|
148
|
-
targetType: "booking_payment_schedule",
|
|
149
|
-
targetId: schedule.id,
|
|
150
|
-
dedupeKey,
|
|
151
|
-
bookingId: schedule.bookingId,
|
|
152
|
-
personId: null,
|
|
153
|
-
organizationId: null,
|
|
154
|
-
paymentSessionId: null,
|
|
155
|
-
notificationDeliveryId: null,
|
|
156
|
-
status: "skipped",
|
|
157
|
-
recipient: null,
|
|
158
|
-
scheduledFor: now,
|
|
159
|
-
processedAt: now,
|
|
160
|
-
errorMessage: "Booking not found for payment schedule",
|
|
161
|
-
metadata: {
|
|
162
|
-
dueDate: schedule.dueDate,
|
|
163
|
-
relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
|
|
164
|
-
},
|
|
165
|
-
})
|
|
166
|
-
.returning();
|
|
167
|
-
return run ?? null;
|
|
168
|
-
}
|
|
169
|
-
const participants = await db
|
|
170
|
-
.select({
|
|
171
|
-
id: bookingParticipants.id,
|
|
172
|
-
firstName: bookingParticipants.firstName,
|
|
173
|
-
lastName: bookingParticipants.lastName,
|
|
174
|
-
email: bookingParticipants.email,
|
|
175
|
-
participantType: bookingParticipants.participantType,
|
|
176
|
-
isPrimary: bookingParticipants.isPrimary,
|
|
177
|
-
})
|
|
178
|
-
.from(bookingParticipants)
|
|
179
|
-
.where(eq(bookingParticipants.bookingId, booking.id))
|
|
180
|
-
.orderBy(desc(bookingParticipants.isPrimary), bookingParticipants.createdAt);
|
|
181
|
-
const recipient = resolveReminderRecipient(participants);
|
|
182
|
-
const [processingRun] = await db
|
|
183
|
-
.insert(notificationReminderRuns)
|
|
184
|
-
.values({
|
|
185
|
-
reminderRuleId: rule.id,
|
|
186
|
-
targetType: "booking_payment_schedule",
|
|
187
|
-
targetId: schedule.id,
|
|
188
|
-
dedupeKey,
|
|
189
|
-
bookingId: booking.id,
|
|
190
|
-
personId: booking.personId ?? null,
|
|
191
|
-
organizationId: booking.organizationId ?? null,
|
|
192
|
-
paymentSessionId: null,
|
|
193
|
-
notificationDeliveryId: null,
|
|
194
|
-
status: "processing",
|
|
195
|
-
recipient: recipient?.email ?? null,
|
|
196
|
-
scheduledFor: now,
|
|
197
|
-
processedAt: now,
|
|
198
|
-
errorMessage: null,
|
|
199
|
-
metadata: {
|
|
200
|
-
dueDate: schedule.dueDate,
|
|
201
|
-
relativeDaysFromDueDate: rule.relativeDaysFromDueDate,
|
|
202
|
-
bookingNumber: booking.bookingNumber,
|
|
203
|
-
},
|
|
204
|
-
})
|
|
205
|
-
.onConflictDoNothing({ target: notificationReminderRuns.dedupeKey })
|
|
206
|
-
.returning();
|
|
207
|
-
if (!processingRun) {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
if (!recipient?.email) {
|
|
211
|
-
const [run] = await db
|
|
212
|
-
.update(notificationReminderRuns)
|
|
213
|
-
.set({
|
|
214
|
-
status: "skipped",
|
|
215
|
-
errorMessage: "No participant email available for booking payment reminder",
|
|
216
|
-
processedAt: now,
|
|
217
|
-
updatedAt: now,
|
|
218
|
-
})
|
|
219
|
-
.where(eq(notificationReminderRuns.id, processingRun.id))
|
|
220
|
-
.returning();
|
|
221
|
-
return run ?? null;
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
const delivery = await notificationsService.sendNotification(db, dispatcher, {
|
|
225
|
-
templateId: rule.templateId ?? null,
|
|
226
|
-
templateSlug: rule.templateSlug ?? null,
|
|
227
|
-
channel: rule.channel,
|
|
228
|
-
provider: rule.provider ?? null,
|
|
229
|
-
to: recipient.email,
|
|
230
|
-
data: {
|
|
231
|
-
bookingId: booking.id,
|
|
232
|
-
bookingNumber: booking.bookingNumber,
|
|
233
|
-
dueDate: schedule.dueDate,
|
|
234
|
-
amountCents: schedule.amountCents,
|
|
235
|
-
currency: schedule.currency,
|
|
236
|
-
scheduleType: schedule.scheduleType,
|
|
237
|
-
reminderOffsetDays: rule.relativeDaysFromDueDate,
|
|
238
|
-
participant: {
|
|
239
|
-
firstName: recipient.firstName,
|
|
240
|
-
lastName: recipient.lastName,
|
|
241
|
-
email: recipient.email,
|
|
242
|
-
},
|
|
243
|
-
booking: {
|
|
244
|
-
id: booking.id,
|
|
245
|
-
bookingNumber: booking.bookingNumber,
|
|
246
|
-
startDate: booking.startDate,
|
|
247
|
-
endDate: booking.endDate,
|
|
248
|
-
sellCurrency: booking.sellCurrency,
|
|
249
|
-
sellAmountCents: booking.sellAmountCents,
|
|
250
|
-
},
|
|
251
|
-
paymentSchedule: {
|
|
252
|
-
id: schedule.id,
|
|
253
|
-
dueDate: schedule.dueDate,
|
|
254
|
-
amountCents: schedule.amountCents,
|
|
255
|
-
currency: schedule.currency,
|
|
256
|
-
scheduleType: schedule.scheduleType,
|
|
257
|
-
status: schedule.status,
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
targetType: "booking_payment_schedule",
|
|
261
|
-
targetId: schedule.id,
|
|
262
|
-
bookingId: booking.id,
|
|
263
|
-
personId: booking.personId ?? null,
|
|
264
|
-
organizationId: booking.organizationId ?? null,
|
|
265
|
-
metadata: {
|
|
266
|
-
reminderRuleId: rule.id,
|
|
267
|
-
reminderRunId: processingRun.id,
|
|
268
|
-
},
|
|
269
|
-
scheduledFor: now.toISOString(),
|
|
270
|
-
});
|
|
271
|
-
const [run] = await db
|
|
272
|
-
.update(notificationReminderRuns)
|
|
273
|
-
.set({
|
|
274
|
-
notificationDeliveryId: delivery?.id ?? null,
|
|
275
|
-
status: "sent",
|
|
276
|
-
processedAt: new Date(),
|
|
277
|
-
updatedAt: new Date(),
|
|
278
|
-
errorMessage: null,
|
|
279
|
-
})
|
|
280
|
-
.where(eq(notificationReminderRuns.id, processingRun.id))
|
|
281
|
-
.returning();
|
|
282
|
-
return run ?? null;
|
|
283
|
-
}
|
|
284
|
-
catch (error) {
|
|
285
|
-
const message = error instanceof Error ? error.message : "Notification reminder failed";
|
|
286
|
-
const [run] = await db
|
|
287
|
-
.update(notificationReminderRuns)
|
|
288
|
-
.set({
|
|
289
|
-
status: "failed",
|
|
290
|
-
errorMessage: message,
|
|
291
|
-
processedAt: new Date(),
|
|
292
|
-
updatedAt: new Date(),
|
|
293
|
-
})
|
|
294
|
-
.where(eq(notificationReminderRuns.id, processingRun.id))
|
|
295
|
-
.returning();
|
|
296
|
-
return run ?? null;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
1
|
+
export { createNotificationService, NotificationError, renderNotificationTemplate, } from "./service-shared.js";
|
|
2
|
+
import { getDeliveryById, listDeliveries, sendInvoiceNotification, sendNotification, sendPaymentSessionNotification, } from "./service-deliveries.js";
|
|
3
|
+
import { runDueReminders } from "./service-reminders.js";
|
|
4
|
+
import { createReminderRule, createTemplate, getReminderRuleById, getTemplateById, getTemplateBySlug, listReminderRules, listReminderRuns, listTemplates, updateReminderRule, updateTemplate, } from "./service-templates.js";
|
|
299
5
|
export const notificationsService = {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
.where(where)
|
|
317
|
-
.limit(query.limit)
|
|
318
|
-
.offset(query.offset)
|
|
319
|
-
.orderBy(desc(notificationTemplates.updatedAt)), db.select({ total: sql `count(*)::int` }).from(notificationTemplates).where(where), query.limit, query.offset);
|
|
320
|
-
},
|
|
321
|
-
async getTemplateById(db, id) {
|
|
322
|
-
const [row] = await db
|
|
323
|
-
.select()
|
|
324
|
-
.from(notificationTemplates)
|
|
325
|
-
.where(eq(notificationTemplates.id, id))
|
|
326
|
-
.limit(1);
|
|
327
|
-
return row ?? null;
|
|
328
|
-
},
|
|
329
|
-
async getTemplateBySlug(db, slug) {
|
|
330
|
-
const [row] = await db
|
|
331
|
-
.select()
|
|
332
|
-
.from(notificationTemplates)
|
|
333
|
-
.where(eq(notificationTemplates.slug, slug))
|
|
334
|
-
.limit(1);
|
|
335
|
-
return row ?? null;
|
|
336
|
-
},
|
|
337
|
-
async createTemplate(db, data) {
|
|
338
|
-
const [row] = await db.insert(notificationTemplates).values(data).returning();
|
|
339
|
-
return row ?? null;
|
|
340
|
-
},
|
|
341
|
-
async updateTemplate(db, id, data) {
|
|
342
|
-
const [row] = await db
|
|
343
|
-
.update(notificationTemplates)
|
|
344
|
-
.set({ ...data, updatedAt: new Date() })
|
|
345
|
-
.where(eq(notificationTemplates.id, id))
|
|
346
|
-
.returning();
|
|
347
|
-
return row ?? null;
|
|
348
|
-
},
|
|
349
|
-
async listDeliveries(db, query) {
|
|
350
|
-
const conditions = [];
|
|
351
|
-
if (query.channel)
|
|
352
|
-
conditions.push(eq(notificationDeliveries.channel, query.channel));
|
|
353
|
-
if (query.provider)
|
|
354
|
-
conditions.push(eq(notificationDeliveries.provider, query.provider));
|
|
355
|
-
if (query.status)
|
|
356
|
-
conditions.push(eq(notificationDeliveries.status, query.status));
|
|
357
|
-
if (query.templateSlug)
|
|
358
|
-
conditions.push(eq(notificationDeliveries.templateSlug, query.templateSlug));
|
|
359
|
-
if (query.targetType)
|
|
360
|
-
conditions.push(eq(notificationDeliveries.targetType, query.targetType));
|
|
361
|
-
if (query.targetId)
|
|
362
|
-
conditions.push(eq(notificationDeliveries.targetId, query.targetId));
|
|
363
|
-
if (query.bookingId)
|
|
364
|
-
conditions.push(eq(notificationDeliveries.bookingId, query.bookingId));
|
|
365
|
-
if (query.invoiceId)
|
|
366
|
-
conditions.push(eq(notificationDeliveries.invoiceId, query.invoiceId));
|
|
367
|
-
if (query.paymentSessionId)
|
|
368
|
-
conditions.push(eq(notificationDeliveries.paymentSessionId, query.paymentSessionId));
|
|
369
|
-
if (query.personId)
|
|
370
|
-
conditions.push(eq(notificationDeliveries.personId, query.personId));
|
|
371
|
-
if (query.organizationId)
|
|
372
|
-
conditions.push(eq(notificationDeliveries.organizationId, query.organizationId));
|
|
373
|
-
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
374
|
-
return paginate(db
|
|
375
|
-
.select()
|
|
376
|
-
.from(notificationDeliveries)
|
|
377
|
-
.where(where)
|
|
378
|
-
.limit(query.limit)
|
|
379
|
-
.offset(query.offset)
|
|
380
|
-
.orderBy(desc(notificationDeliveries.createdAt)), db.select({ total: sql `count(*)::int` }).from(notificationDeliveries).where(where), query.limit, query.offset);
|
|
381
|
-
},
|
|
382
|
-
async getDeliveryById(db, id) {
|
|
383
|
-
const [row] = await db
|
|
384
|
-
.select()
|
|
385
|
-
.from(notificationDeliveries)
|
|
386
|
-
.where(eq(notificationDeliveries.id, id))
|
|
387
|
-
.limit(1);
|
|
388
|
-
return row ?? null;
|
|
389
|
-
},
|
|
390
|
-
async sendNotification(db, dispatcher, input) {
|
|
391
|
-
let template = null;
|
|
392
|
-
if (input.templateId) {
|
|
393
|
-
template = await this.getTemplateById(db, input.templateId);
|
|
394
|
-
}
|
|
395
|
-
else if (input.templateSlug) {
|
|
396
|
-
template = await this.getTemplateBySlug(db, input.templateSlug);
|
|
397
|
-
}
|
|
398
|
-
if ((input.templateId || input.templateSlug) && !template) {
|
|
399
|
-
throw new NotificationError("Notification template not found");
|
|
400
|
-
}
|
|
401
|
-
const data = input.data ?? {};
|
|
402
|
-
const channel = input.channel ?? template?.channel;
|
|
403
|
-
if (!channel) {
|
|
404
|
-
throw new NotificationError("Notification channel is required");
|
|
405
|
-
}
|
|
406
|
-
const provider = input.provider ?? template?.provider ?? dispatcher.getProvider(channel)?.name;
|
|
407
|
-
if (!provider) {
|
|
408
|
-
throw new NotificationError(`No notification provider available for channel "${channel}"`);
|
|
409
|
-
}
|
|
410
|
-
const subject = input.subject ?? renderNotificationTemplate(template?.subjectTemplate, data);
|
|
411
|
-
const html = input.html ?? renderNotificationTemplate(template?.htmlTemplate, data);
|
|
412
|
-
const text = input.text ?? renderNotificationTemplate(template?.textTemplate, data);
|
|
413
|
-
const [pending] = await db
|
|
414
|
-
.insert(notificationDeliveries)
|
|
415
|
-
.values({
|
|
416
|
-
templateId: template?.id ?? null,
|
|
417
|
-
templateSlug: template?.slug ?? input.templateSlug ?? null,
|
|
418
|
-
targetType: input.targetType,
|
|
419
|
-
targetId: input.targetId ?? null,
|
|
420
|
-
personId: input.personId ?? null,
|
|
421
|
-
organizationId: input.organizationId ?? null,
|
|
422
|
-
bookingId: input.bookingId ?? null,
|
|
423
|
-
invoiceId: input.invoiceId ?? null,
|
|
424
|
-
paymentSessionId: input.paymentSessionId ?? null,
|
|
425
|
-
channel,
|
|
426
|
-
provider,
|
|
427
|
-
providerMessageId: null,
|
|
428
|
-
status: "pending",
|
|
429
|
-
toAddress: input.to,
|
|
430
|
-
fromAddress: input.from ?? template?.fromAddress ?? null,
|
|
431
|
-
subject: subject ?? null,
|
|
432
|
-
htmlBody: html ?? null,
|
|
433
|
-
textBody: text ?? null,
|
|
434
|
-
payloadData: data,
|
|
435
|
-
metadata: input.metadata ?? null,
|
|
436
|
-
errorMessage: null,
|
|
437
|
-
scheduledFor: toTimestamp(input.scheduledFor),
|
|
438
|
-
sentAt: null,
|
|
439
|
-
failedAt: null,
|
|
440
|
-
})
|
|
441
|
-
.returning();
|
|
442
|
-
if (!pending) {
|
|
443
|
-
throw new NotificationError("Failed to create notification delivery");
|
|
444
|
-
}
|
|
445
|
-
try {
|
|
446
|
-
const result = provider === dispatcher.getProvider(channel)?.name
|
|
447
|
-
? await dispatcher.send({
|
|
448
|
-
to: input.to,
|
|
449
|
-
channel,
|
|
450
|
-
provider,
|
|
451
|
-
template: template?.slug ?? input.templateSlug ?? "direct",
|
|
452
|
-
data,
|
|
453
|
-
from: input.from ?? template?.fromAddress ?? undefined,
|
|
454
|
-
subject: subject ?? undefined,
|
|
455
|
-
html: html ?? undefined,
|
|
456
|
-
text: text ?? undefined,
|
|
457
|
-
})
|
|
458
|
-
: await dispatcher.sendWith(provider, {
|
|
459
|
-
to: input.to,
|
|
460
|
-
channel,
|
|
461
|
-
provider,
|
|
462
|
-
template: template?.slug ?? input.templateSlug ?? "direct",
|
|
463
|
-
data,
|
|
464
|
-
from: input.from ?? template?.fromAddress ?? undefined,
|
|
465
|
-
subject: subject ?? undefined,
|
|
466
|
-
html: html ?? undefined,
|
|
467
|
-
text: text ?? undefined,
|
|
468
|
-
});
|
|
469
|
-
const [sent] = await db
|
|
470
|
-
.update(notificationDeliveries)
|
|
471
|
-
.set({
|
|
472
|
-
status: "sent",
|
|
473
|
-
providerMessageId: result.id ?? null,
|
|
474
|
-
sentAt: new Date(),
|
|
475
|
-
errorMessage: null,
|
|
476
|
-
updatedAt: new Date(),
|
|
477
|
-
})
|
|
478
|
-
.where(eq(notificationDeliveries.id, pending.id))
|
|
479
|
-
.returning();
|
|
480
|
-
return sent ?? null;
|
|
481
|
-
}
|
|
482
|
-
catch (error) {
|
|
483
|
-
const message = error instanceof Error ? error.message : "Notification send failed";
|
|
484
|
-
const [failed] = await db
|
|
485
|
-
.update(notificationDeliveries)
|
|
486
|
-
.set({
|
|
487
|
-
status: "failed",
|
|
488
|
-
failedAt: new Date(),
|
|
489
|
-
errorMessage: message,
|
|
490
|
-
updatedAt: new Date(),
|
|
491
|
-
})
|
|
492
|
-
.where(eq(notificationDeliveries.id, pending.id))
|
|
493
|
-
.returning();
|
|
494
|
-
throw new NotificationError(failed?.errorMessage ?? message);
|
|
495
|
-
}
|
|
496
|
-
},
|
|
497
|
-
async listReminderRules(db, query) {
|
|
498
|
-
const conditions = [];
|
|
499
|
-
if (query.status)
|
|
500
|
-
conditions.push(eq(notificationReminderRules.status, query.status));
|
|
501
|
-
if (query.targetType)
|
|
502
|
-
conditions.push(eq(notificationReminderRules.targetType, query.targetType));
|
|
503
|
-
if (query.channel)
|
|
504
|
-
conditions.push(eq(notificationReminderRules.channel, query.channel));
|
|
505
|
-
if (query.search) {
|
|
506
|
-
const term = `%${query.search}%`;
|
|
507
|
-
conditions.push(or(ilike(notificationReminderRules.slug, term), ilike(notificationReminderRules.name, term)));
|
|
508
|
-
}
|
|
509
|
-
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
510
|
-
return paginate(db
|
|
511
|
-
.select()
|
|
512
|
-
.from(notificationReminderRules)
|
|
513
|
-
.where(where)
|
|
514
|
-
.limit(query.limit)
|
|
515
|
-
.offset(query.offset)
|
|
516
|
-
.orderBy(desc(notificationReminderRules.updatedAt)), db.select({ total: sql `count(*)::int` }).from(notificationReminderRules).where(where), query.limit, query.offset);
|
|
517
|
-
},
|
|
518
|
-
async getReminderRuleById(db, id) {
|
|
519
|
-
const [row] = await db
|
|
520
|
-
.select()
|
|
521
|
-
.from(notificationReminderRules)
|
|
522
|
-
.where(eq(notificationReminderRules.id, id))
|
|
523
|
-
.limit(1);
|
|
524
|
-
return row ?? null;
|
|
525
|
-
},
|
|
526
|
-
async createReminderRule(db, data) {
|
|
527
|
-
const [row] = await db.insert(notificationReminderRules).values(data).returning();
|
|
528
|
-
return row ?? null;
|
|
529
|
-
},
|
|
530
|
-
async updateReminderRule(db, id, data) {
|
|
531
|
-
const [row] = await db
|
|
532
|
-
.update(notificationReminderRules)
|
|
533
|
-
.set({ ...data, updatedAt: new Date() })
|
|
534
|
-
.where(eq(notificationReminderRules.id, id))
|
|
535
|
-
.returning();
|
|
536
|
-
return row ?? null;
|
|
537
|
-
},
|
|
538
|
-
async listReminderRuns(db, query) {
|
|
539
|
-
const conditions = [];
|
|
540
|
-
if (query.reminderRuleId) {
|
|
541
|
-
conditions.push(eq(notificationReminderRuns.reminderRuleId, query.reminderRuleId));
|
|
542
|
-
}
|
|
543
|
-
if (query.targetType)
|
|
544
|
-
conditions.push(eq(notificationReminderRuns.targetType, query.targetType));
|
|
545
|
-
if (query.targetId)
|
|
546
|
-
conditions.push(eq(notificationReminderRuns.targetId, query.targetId));
|
|
547
|
-
if (query.bookingId)
|
|
548
|
-
conditions.push(eq(notificationReminderRuns.bookingId, query.bookingId));
|
|
549
|
-
if (query.status)
|
|
550
|
-
conditions.push(eq(notificationReminderRuns.status, query.status));
|
|
551
|
-
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
552
|
-
return paginate(db
|
|
553
|
-
.select()
|
|
554
|
-
.from(notificationReminderRuns)
|
|
555
|
-
.where(where)
|
|
556
|
-
.limit(query.limit)
|
|
557
|
-
.offset(query.offset)
|
|
558
|
-
.orderBy(desc(notificationReminderRuns.createdAt)), db.select({ total: sql `count(*)::int` }).from(notificationReminderRuns).where(where), query.limit, query.offset);
|
|
559
|
-
},
|
|
560
|
-
async runDueReminders(db, dispatcher, input = {}) {
|
|
561
|
-
const now = toTimestamp(input.now) ?? new Date();
|
|
562
|
-
const today = startOfUtcDay(now);
|
|
563
|
-
const activeRules = await db
|
|
564
|
-
.select()
|
|
565
|
-
.from(notificationReminderRules)
|
|
566
|
-
.where(eq(notificationReminderRules.status, "active"))
|
|
567
|
-
.orderBy(notificationReminderRules.createdAt);
|
|
568
|
-
const summary = {
|
|
569
|
-
processed: 0,
|
|
570
|
-
sent: 0,
|
|
571
|
-
skipped: 0,
|
|
572
|
-
failed: 0,
|
|
573
|
-
};
|
|
574
|
-
for (const rule of activeRules) {
|
|
575
|
-
const matchingDueDate = toDateString(addUtcDays(today, -rule.relativeDaysFromDueDate));
|
|
576
|
-
const schedules = await db
|
|
577
|
-
.select()
|
|
578
|
-
.from(bookingPaymentSchedules)
|
|
579
|
-
.where(and(eq(bookingPaymentSchedules.dueDate, matchingDueDate), or(eq(bookingPaymentSchedules.status, "pending"), eq(bookingPaymentSchedules.status, "due"))))
|
|
580
|
-
.orderBy(bookingPaymentSchedules.createdAt);
|
|
581
|
-
for (const schedule of schedules) {
|
|
582
|
-
const run = await sendBookingPaymentScheduleReminder(db, dispatcher, rule, schedule, now);
|
|
583
|
-
if (!run) {
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
summary.processed += 1;
|
|
587
|
-
if (run.status === "sent")
|
|
588
|
-
summary.sent += 1;
|
|
589
|
-
if (run.status === "skipped")
|
|
590
|
-
summary.skipped += 1;
|
|
591
|
-
if (run.status === "failed")
|
|
592
|
-
summary.failed += 1;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
return summary;
|
|
596
|
-
},
|
|
597
|
-
async sendPaymentSessionNotification(db, dispatcher, sessionId, input) {
|
|
598
|
-
const [session] = await db
|
|
599
|
-
.select()
|
|
600
|
-
.from(paymentSessions)
|
|
601
|
-
.where(eq(paymentSessions.id, sessionId))
|
|
602
|
-
.limit(1);
|
|
603
|
-
if (!session) {
|
|
604
|
-
return null;
|
|
605
|
-
}
|
|
606
|
-
const booking = session.bookingId
|
|
607
|
-
? ((await db.select().from(bookings).where(eq(bookings.id, session.bookingId)).limit(1))[0] ??
|
|
608
|
-
null)
|
|
609
|
-
: null;
|
|
610
|
-
const invoice = session.invoiceId
|
|
611
|
-
? ((await db.select().from(invoices).where(eq(invoices.id, session.invoiceId)).limit(1))[0] ??
|
|
612
|
-
null)
|
|
613
|
-
: null;
|
|
614
|
-
const participants = booking ? await listBookingNotificationParticipants(db, booking.id) : [];
|
|
615
|
-
const recipient = resolveReminderRecipient(participants);
|
|
616
|
-
const to = input.to ?? session.payerEmail ?? recipient?.email ?? null;
|
|
617
|
-
if (!to) {
|
|
618
|
-
throw new NotificationError("No recipient available for payment session notification");
|
|
619
|
-
}
|
|
620
|
-
return this.sendNotification(db, dispatcher, {
|
|
621
|
-
templateId: input.templateId ?? null,
|
|
622
|
-
templateSlug: input.templateSlug ?? null,
|
|
623
|
-
channel: input.channel,
|
|
624
|
-
provider: input.provider ?? null,
|
|
625
|
-
to,
|
|
626
|
-
from: input.from ?? null,
|
|
627
|
-
subject: input.subject ?? null,
|
|
628
|
-
html: input.html ?? null,
|
|
629
|
-
text: input.text ?? null,
|
|
630
|
-
data: {
|
|
631
|
-
paymentSession: {
|
|
632
|
-
id: session.id,
|
|
633
|
-
status: session.status,
|
|
634
|
-
provider: session.provider,
|
|
635
|
-
currency: session.currency,
|
|
636
|
-
amountCents: session.amountCents,
|
|
637
|
-
redirectUrl: session.redirectUrl,
|
|
638
|
-
returnUrl: session.returnUrl,
|
|
639
|
-
cancelUrl: session.cancelUrl,
|
|
640
|
-
expiresAt: session.expiresAt,
|
|
641
|
-
paymentMethod: session.paymentMethod,
|
|
642
|
-
externalReference: session.externalReference,
|
|
643
|
-
},
|
|
644
|
-
booking: booking
|
|
645
|
-
? {
|
|
646
|
-
id: booking.id,
|
|
647
|
-
bookingNumber: booking.bookingNumber,
|
|
648
|
-
startDate: booking.startDate,
|
|
649
|
-
endDate: booking.endDate,
|
|
650
|
-
sellCurrency: booking.sellCurrency,
|
|
651
|
-
sellAmountCents: booking.sellAmountCents,
|
|
652
|
-
}
|
|
653
|
-
: null,
|
|
654
|
-
invoice: invoice
|
|
655
|
-
? {
|
|
656
|
-
id: invoice.id,
|
|
657
|
-
invoiceNumber: invoice.invoiceNumber,
|
|
658
|
-
invoiceType: invoice.invoiceType,
|
|
659
|
-
status: invoice.status,
|
|
660
|
-
currency: invoice.currency,
|
|
661
|
-
totalCents: invoice.totalCents,
|
|
662
|
-
balanceDueCents: invoice.balanceDueCents,
|
|
663
|
-
issueDate: invoice.issueDate,
|
|
664
|
-
dueDate: invoice.dueDate,
|
|
665
|
-
}
|
|
666
|
-
: null,
|
|
667
|
-
participant: recipient
|
|
668
|
-
? {
|
|
669
|
-
firstName: recipient.firstName,
|
|
670
|
-
lastName: recipient.lastName,
|
|
671
|
-
email: recipient.email,
|
|
672
|
-
}
|
|
673
|
-
: null,
|
|
674
|
-
...(input.data ?? {}),
|
|
675
|
-
},
|
|
676
|
-
targetType: "payment_session",
|
|
677
|
-
targetId: session.id,
|
|
678
|
-
bookingId: session.bookingId ?? null,
|
|
679
|
-
invoiceId: session.invoiceId ?? null,
|
|
680
|
-
paymentSessionId: session.id,
|
|
681
|
-
personId: session.payerPersonId ?? booking?.personId ?? null,
|
|
682
|
-
organizationId: session.payerOrganizationId ?? booking?.organizationId ?? null,
|
|
683
|
-
metadata: input.metadata ?? null,
|
|
684
|
-
scheduledFor: input.scheduledFor ?? null,
|
|
685
|
-
});
|
|
686
|
-
},
|
|
687
|
-
async sendInvoiceNotification(db, dispatcher, invoiceId, input) {
|
|
688
|
-
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
|
689
|
-
if (!invoice) {
|
|
690
|
-
return null;
|
|
691
|
-
}
|
|
692
|
-
const [booking] = await db
|
|
693
|
-
.select()
|
|
694
|
-
.from(bookings)
|
|
695
|
-
.where(eq(bookings.id, invoice.bookingId))
|
|
696
|
-
.limit(1);
|
|
697
|
-
const participants = booking ? await listBookingNotificationParticipants(db, booking.id) : [];
|
|
698
|
-
const recipient = resolveReminderRecipient(participants);
|
|
699
|
-
const [latestSession] = await db
|
|
700
|
-
.select()
|
|
701
|
-
.from(paymentSessions)
|
|
702
|
-
.where(eq(paymentSessions.invoiceId, invoice.id))
|
|
703
|
-
.orderBy(desc(paymentSessions.createdAt))
|
|
704
|
-
.limit(1);
|
|
705
|
-
const to = input.to ?? latestSession?.payerEmail ?? recipient?.email ?? null;
|
|
706
|
-
if (!to) {
|
|
707
|
-
throw new NotificationError("No recipient available for invoice notification");
|
|
708
|
-
}
|
|
709
|
-
return this.sendNotification(db, dispatcher, {
|
|
710
|
-
templateId: input.templateId ?? null,
|
|
711
|
-
templateSlug: input.templateSlug ?? null,
|
|
712
|
-
channel: input.channel,
|
|
713
|
-
provider: input.provider ?? null,
|
|
714
|
-
to,
|
|
715
|
-
from: input.from ?? null,
|
|
716
|
-
subject: input.subject ?? null,
|
|
717
|
-
html: input.html ?? null,
|
|
718
|
-
text: input.text ?? null,
|
|
719
|
-
data: {
|
|
720
|
-
invoice: {
|
|
721
|
-
id: invoice.id,
|
|
722
|
-
invoiceNumber: invoice.invoiceNumber,
|
|
723
|
-
invoiceType: invoice.invoiceType,
|
|
724
|
-
status: invoice.status,
|
|
725
|
-
currency: invoice.currency,
|
|
726
|
-
subtotalCents: invoice.subtotalCents,
|
|
727
|
-
taxCents: invoice.taxCents,
|
|
728
|
-
totalCents: invoice.totalCents,
|
|
729
|
-
paidCents: invoice.paidCents,
|
|
730
|
-
balanceDueCents: invoice.balanceDueCents,
|
|
731
|
-
issueDate: invoice.issueDate,
|
|
732
|
-
dueDate: invoice.dueDate,
|
|
733
|
-
},
|
|
734
|
-
booking: booking
|
|
735
|
-
? {
|
|
736
|
-
id: booking.id,
|
|
737
|
-
bookingNumber: booking.bookingNumber,
|
|
738
|
-
startDate: booking.startDate,
|
|
739
|
-
endDate: booking.endDate,
|
|
740
|
-
sellCurrency: booking.sellCurrency,
|
|
741
|
-
sellAmountCents: booking.sellAmountCents,
|
|
742
|
-
}
|
|
743
|
-
: null,
|
|
744
|
-
paymentSession: latestSession
|
|
745
|
-
? {
|
|
746
|
-
id: latestSession.id,
|
|
747
|
-
status: latestSession.status,
|
|
748
|
-
provider: latestSession.provider,
|
|
749
|
-
redirectUrl: latestSession.redirectUrl,
|
|
750
|
-
expiresAt: latestSession.expiresAt,
|
|
751
|
-
amountCents: latestSession.amountCents,
|
|
752
|
-
currency: latestSession.currency,
|
|
753
|
-
}
|
|
754
|
-
: null,
|
|
755
|
-
participant: recipient
|
|
756
|
-
? {
|
|
757
|
-
firstName: recipient.firstName,
|
|
758
|
-
lastName: recipient.lastName,
|
|
759
|
-
email: recipient.email,
|
|
760
|
-
}
|
|
761
|
-
: null,
|
|
762
|
-
...(input.data ?? {}),
|
|
763
|
-
},
|
|
764
|
-
targetType: "invoice",
|
|
765
|
-
targetId: invoice.id,
|
|
766
|
-
bookingId: invoice.bookingId,
|
|
767
|
-
invoiceId: invoice.id,
|
|
768
|
-
paymentSessionId: latestSession?.id ?? null,
|
|
769
|
-
personId: invoice.personId ?? booking?.personId ?? null,
|
|
770
|
-
organizationId: invoice.organizationId ?? booking?.organizationId ?? null,
|
|
771
|
-
metadata: input.metadata ?? null,
|
|
772
|
-
scheduledFor: input.scheduledFor ?? null,
|
|
773
|
-
});
|
|
774
|
-
},
|
|
6
|
+
listTemplates,
|
|
7
|
+
getTemplateById,
|
|
8
|
+
getTemplateBySlug,
|
|
9
|
+
createTemplate,
|
|
10
|
+
updateTemplate,
|
|
11
|
+
listDeliveries,
|
|
12
|
+
getDeliveryById,
|
|
13
|
+
sendNotification,
|
|
14
|
+
listReminderRules,
|
|
15
|
+
getReminderRuleById,
|
|
16
|
+
createReminderRule,
|
|
17
|
+
updateReminderRule,
|
|
18
|
+
listReminderRuns,
|
|
19
|
+
runDueReminders,
|
|
20
|
+
sendPaymentSessionNotification,
|
|
21
|
+
sendInvoiceNotification,
|
|
775
22
|
};
|