@thebookingkit/server 0.1.1
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/.turbo/turbo-build.log +6 -0
- package/.turbo/turbo-test.log +20 -0
- package/CHANGELOG.md +9 -0
- package/dist/__tests__/api.test.d.ts +2 -0
- package/dist/__tests__/api.test.d.ts.map +1 -0
- package/dist/__tests__/api.test.js +280 -0
- package/dist/__tests__/api.test.js.map +1 -0
- package/dist/__tests__/auth.test.d.ts +2 -0
- package/dist/__tests__/auth.test.d.ts.map +1 -0
- package/dist/__tests__/auth.test.js +78 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/concurrent-booking.test.d.ts +2 -0
- package/dist/__tests__/concurrent-booking.test.d.ts.map +1 -0
- package/dist/__tests__/concurrent-booking.test.js +111 -0
- package/dist/__tests__/concurrent-booking.test.js.map +1 -0
- package/dist/__tests__/multi-tenancy.test.d.ts +2 -0
- package/dist/__tests__/multi-tenancy.test.d.ts.map +1 -0
- package/dist/__tests__/multi-tenancy.test.js +196 -0
- package/dist/__tests__/multi-tenancy.test.js.map +1 -0
- package/dist/__tests__/serialization-retry.test.d.ts +2 -0
- package/dist/__tests__/serialization-retry.test.d.ts.map +1 -0
- package/dist/__tests__/serialization-retry.test.js +53 -0
- package/dist/__tests__/serialization-retry.test.js.map +1 -0
- package/dist/__tests__/webhooks.test.d.ts +2 -0
- package/dist/__tests__/webhooks.test.d.ts.map +1 -0
- package/dist/__tests__/webhooks.test.js +286 -0
- package/dist/__tests__/webhooks.test.js.map +1 -0
- package/dist/__tests__/workflows.test.d.ts +2 -0
- package/dist/__tests__/workflows.test.d.ts.map +1 -0
- package/dist/__tests__/workflows.test.js +299 -0
- package/dist/__tests__/workflows.test.js.map +1 -0
- package/dist/adapters/calendar-adapter.d.ts +47 -0
- package/dist/adapters/calendar-adapter.d.ts.map +1 -0
- package/dist/adapters/calendar-adapter.js +2 -0
- package/dist/adapters/calendar-adapter.js.map +1 -0
- package/dist/adapters/email-adapter.d.ts +65 -0
- package/dist/adapters/email-adapter.d.ts.map +1 -0
- package/dist/adapters/email-adapter.js +40 -0
- package/dist/adapters/email-adapter.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +3 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/job-adapter.d.ts +26 -0
- package/dist/adapters/job-adapter.d.ts.map +1 -0
- package/dist/adapters/job-adapter.js +13 -0
- package/dist/adapters/job-adapter.js.map +1 -0
- package/dist/adapters/payment-adapter.d.ts +106 -0
- package/dist/adapters/payment-adapter.d.ts.map +1 -0
- package/dist/adapters/payment-adapter.js +8 -0
- package/dist/adapters/payment-adapter.js.map +1 -0
- package/dist/adapters/sms-adapter.d.ts +33 -0
- package/dist/adapters/sms-adapter.d.ts.map +1 -0
- package/dist/adapters/sms-adapter.js +8 -0
- package/dist/adapters/sms-adapter.js.map +1 -0
- package/dist/adapters/storage-adapter.d.ts +12 -0
- package/dist/adapters/storage-adapter.d.ts.map +1 -0
- package/dist/adapters/storage-adapter.js +2 -0
- package/dist/adapters/storage-adapter.js.map +1 -0
- package/dist/api.d.ts +223 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +271 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +71 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +81 -0
- package/dist/auth.js.map +1 -0
- package/dist/booking-tokens.d.ts +23 -0
- package/dist/booking-tokens.d.ts.map +1 -0
- package/dist/booking-tokens.js +52 -0
- package/dist/booking-tokens.js.map +1 -0
- package/dist/email-templates.d.ts +36 -0
- package/dist/email-templates.d.ts.map +1 -0
- package/dist/email-templates.js +112 -0
- package/dist/email-templates.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/multi-tenancy.d.ts +132 -0
- package/dist/multi-tenancy.d.ts.map +1 -0
- package/dist/multi-tenancy.js +188 -0
- package/dist/multi-tenancy.js.map +1 -0
- package/dist/notification-jobs.d.ts +143 -0
- package/dist/notification-jobs.d.ts.map +1 -0
- package/dist/notification-jobs.js +278 -0
- package/dist/notification-jobs.js.map +1 -0
- package/dist/serialization-retry.d.ts +28 -0
- package/dist/serialization-retry.d.ts.map +1 -0
- package/dist/serialization-retry.js +71 -0
- package/dist/serialization-retry.js.map +1 -0
- package/dist/webhooks.d.ts +164 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +228 -0
- package/dist/webhooks.js.map +1 -0
- package/dist/workflows.d.ts +169 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +251 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +32 -0
- package/src/__tests__/api.test.ts +354 -0
- package/src/__tests__/auth.test.ts +111 -0
- package/src/__tests__/concurrent-booking.test.ts +170 -0
- package/src/__tests__/multi-tenancy.test.ts +267 -0
- package/src/__tests__/serialization-retry.test.ts +76 -0
- package/src/__tests__/webhooks.test.ts +412 -0
- package/src/__tests__/workflows.test.ts +422 -0
- package/src/adapters/calendar-adapter.ts +49 -0
- package/src/adapters/email-adapter.ts +108 -0
- package/src/adapters/index.ts +36 -0
- package/src/adapters/job-adapter.ts +26 -0
- package/src/adapters/payment-adapter.ts +118 -0
- package/src/adapters/sms-adapter.ts +35 -0
- package/src/adapters/storage-adapter.ts +11 -0
- package/src/api.ts +446 -0
- package/src/auth.ts +146 -0
- package/src/booking-tokens.ts +61 -0
- package/src/email-templates.ts +140 -0
- package/src/index.ts +192 -0
- package/src/multi-tenancy.ts +301 -0
- package/src/notification-jobs.ts +428 -0
- package/src/serialization-retry.ts +94 -0
- package/src/webhooks.ts +378 -0
- package/src/workflows.ts +441 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification job payload types and helper functions for E-06.
|
|
3
|
+
*
|
|
4
|
+
* These types are used by background job functions (Inngest, Trigger.dev, BullMQ, etc.)
|
|
5
|
+
* when sending booking notification emails and syncing calendar events.
|
|
6
|
+
*
|
|
7
|
+
* The actual job implementation lives in the consumer app (or a provided Inngest
|
|
8
|
+
* functions file), but the payload shapes and builder functions are framework-agnostic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EmailAdapter, CalendarAdapter, JobAdapter } from "./adapters/index.js";
|
|
12
|
+
import { generateICSAttachment } from "./adapters/index.js";
|
|
13
|
+
import {
|
|
14
|
+
interpolateTemplate,
|
|
15
|
+
CONFIRMATION_EMAIL_HTML,
|
|
16
|
+
CONFIRMATION_EMAIL_TEXT,
|
|
17
|
+
REMINDER_EMAIL_HTML,
|
|
18
|
+
CANCELLATION_EMAIL_HTML,
|
|
19
|
+
RESCHEDULE_EMAIL_HTML,
|
|
20
|
+
type EmailTemplateVars,
|
|
21
|
+
} from "./email-templates.js";
|
|
22
|
+
import { JOB_NAMES } from "./adapters/job-adapter.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Payload types for each notification job
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Common booking data included in every notification payload */
|
|
29
|
+
export interface NotificationBookingData {
|
|
30
|
+
bookingId: string;
|
|
31
|
+
eventTitle: string;
|
|
32
|
+
providerName: string;
|
|
33
|
+
providerEmail: string;
|
|
34
|
+
customerName: string;
|
|
35
|
+
customerEmail: string;
|
|
36
|
+
/** ISO datetime strings */
|
|
37
|
+
startsAt: string;
|
|
38
|
+
endsAt: string;
|
|
39
|
+
timezone: string;
|
|
40
|
+
location?: string;
|
|
41
|
+
/** Signed management URL for the customer */
|
|
42
|
+
managementUrl?: string;
|
|
43
|
+
/** Unsubscribe URL */
|
|
44
|
+
unsubscribeUrl?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Payload for SEND_CONFIRMATION_EMAIL job */
|
|
48
|
+
export interface ConfirmationEmailPayload extends NotificationBookingData {
|
|
49
|
+
/** Whether to also send a notification to the provider */
|
|
50
|
+
notifyProvider?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Payload for SEND_REMINDER_EMAIL job */
|
|
54
|
+
export interface ReminderEmailPayload extends NotificationBookingData {
|
|
55
|
+
/** How many hours before the appointment this reminder is for (e.g., 24 or 1) */
|
|
56
|
+
reminderHours: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Payload for SEND_CANCELLATION_EMAIL job */
|
|
60
|
+
export interface CancellationEmailPayload extends NotificationBookingData {
|
|
61
|
+
/** Who initiated the cancellation */
|
|
62
|
+
cancelledBy: "customer" | "provider" | "system";
|
|
63
|
+
/** Optional reason for cancellation */
|
|
64
|
+
reason?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Payload for SEND_RESCHEDULE_EMAIL job */
|
|
68
|
+
export interface RescheduleEmailPayload extends NotificationBookingData {
|
|
69
|
+
/** Original booking datetime strings */
|
|
70
|
+
oldStartsAt: string;
|
|
71
|
+
oldEndsAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Payload for SYNC_CALENDAR_EVENT job */
|
|
75
|
+
export interface CalendarSyncPayload {
|
|
76
|
+
bookingId: string;
|
|
77
|
+
providerId: string;
|
|
78
|
+
/** External calendar event ID (for updates/deletes) */
|
|
79
|
+
externalEventId?: string;
|
|
80
|
+
eventTitle: string;
|
|
81
|
+
customerName: string;
|
|
82
|
+
customerEmail: string;
|
|
83
|
+
startsAt: string;
|
|
84
|
+
endsAt: string;
|
|
85
|
+
timezone: string;
|
|
86
|
+
location?: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Payload for DELETE_CALENDAR_EVENT job */
|
|
91
|
+
export interface CalendarDeletePayload {
|
|
92
|
+
bookingId: string;
|
|
93
|
+
providerId: string;
|
|
94
|
+
externalEventId: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Payload for AUTO_REJECT_PENDING job */
|
|
98
|
+
export interface AutoRejectPendingPayload {
|
|
99
|
+
bookingId: string;
|
|
100
|
+
/** Actor to record in the booking_event */
|
|
101
|
+
actor?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Helper functions for building notification payloads
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format a UTC datetime string in a given timezone for email templates.
|
|
110
|
+
*
|
|
111
|
+
* @returns `{ date, time }` formatted for the email template variables
|
|
112
|
+
*/
|
|
113
|
+
export function formatDateTimeForEmail(
|
|
114
|
+
isoString: string,
|
|
115
|
+
timezone: string,
|
|
116
|
+
): { date: string; time: string } {
|
|
117
|
+
const dt = new Date(isoString);
|
|
118
|
+
const date = dt.toLocaleDateString("en-US", {
|
|
119
|
+
weekday: "long",
|
|
120
|
+
year: "numeric",
|
|
121
|
+
month: "long",
|
|
122
|
+
day: "numeric",
|
|
123
|
+
timeZone: timezone,
|
|
124
|
+
});
|
|
125
|
+
const time = dt.toLocaleTimeString("en-US", {
|
|
126
|
+
hour: "numeric",
|
|
127
|
+
minute: "2-digit",
|
|
128
|
+
hour12: true,
|
|
129
|
+
timeZone: timezone,
|
|
130
|
+
});
|
|
131
|
+
return { date, time };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Calculate duration in human-readable form from start/end ISO strings.
|
|
136
|
+
*/
|
|
137
|
+
export function formatDurationForEmail(startsAt: string, endsAt: string): string {
|
|
138
|
+
const ms = new Date(endsAt).getTime() - new Date(startsAt).getTime();
|
|
139
|
+
const mins = Math.round(ms / 60000);
|
|
140
|
+
if (mins < 60) return `${mins} minutes`;
|
|
141
|
+
const hours = Math.floor(mins / 60);
|
|
142
|
+
const remainder = mins % 60;
|
|
143
|
+
return remainder === 0
|
|
144
|
+
? `${hours} hour${hours !== 1 ? "s" : ""}`
|
|
145
|
+
: `${hours}h ${remainder}m`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Job execution helpers (framework-agnostic)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Send a booking confirmation email to the customer (and optionally the provider).
|
|
154
|
+
*
|
|
155
|
+
* Call this from your Inngest/Trigger.dev/BullMQ job handler.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* // In your Inngest function:
|
|
160
|
+
* export const sendConfirmation = inngest.createFunction(
|
|
161
|
+
* { id: "send-confirmation-email" },
|
|
162
|
+
* { event: JOB_NAMES.SEND_CONFIRMATION_EMAIL },
|
|
163
|
+
* async ({ event }) => {
|
|
164
|
+
* await sendConfirmationEmail(event.data, emailAdapter);
|
|
165
|
+
* },
|
|
166
|
+
* );
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export async function sendConfirmationEmail(
|
|
170
|
+
payload: ConfirmationEmailPayload,
|
|
171
|
+
emailAdapter: EmailAdapter,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const { date, time } = formatDateTimeForEmail(payload.startsAt, payload.timezone);
|
|
174
|
+
const duration = formatDurationForEmail(payload.startsAt, payload.endsAt);
|
|
175
|
+
|
|
176
|
+
const vars: EmailTemplateVars = {
|
|
177
|
+
bookingId: payload.bookingId,
|
|
178
|
+
eventTitle: payload.eventTitle,
|
|
179
|
+
providerName: payload.providerName,
|
|
180
|
+
customerName: payload.customerName,
|
|
181
|
+
customerEmail: payload.customerEmail,
|
|
182
|
+
date,
|
|
183
|
+
time,
|
|
184
|
+
duration,
|
|
185
|
+
timezone: payload.timezone,
|
|
186
|
+
location: payload.location,
|
|
187
|
+
managementUrl: payload.managementUrl,
|
|
188
|
+
unsubscribeUrl: payload.unsubscribeUrl,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const icsAttachment = generateBookingICS(payload);
|
|
192
|
+
|
|
193
|
+
await emailAdapter.send({
|
|
194
|
+
to: payload.customerEmail,
|
|
195
|
+
subject: `Booking Confirmed: ${payload.eventTitle} on ${date}`,
|
|
196
|
+
html: interpolateTemplate(CONFIRMATION_EMAIL_HTML, vars),
|
|
197
|
+
text: interpolateTemplate(CONFIRMATION_EMAIL_TEXT, vars),
|
|
198
|
+
attachments: icsAttachment ? [icsAttachment] : undefined,
|
|
199
|
+
headers: buildEmailHeaders(payload.unsubscribeUrl),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (payload.notifyProvider) {
|
|
203
|
+
await emailAdapter.send({
|
|
204
|
+
to: payload.providerEmail,
|
|
205
|
+
subject: `New Booking: ${payload.customerName} — ${payload.eventTitle}`,
|
|
206
|
+
html: interpolateTemplate(CONFIRMATION_EMAIL_HTML, {
|
|
207
|
+
...vars,
|
|
208
|
+
customerName: payload.customerName,
|
|
209
|
+
}),
|
|
210
|
+
text: interpolateTemplate(CONFIRMATION_EMAIL_TEXT, vars),
|
|
211
|
+
headers: buildEmailHeaders(),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Send a reminder email to the customer before their appointment.
|
|
218
|
+
*/
|
|
219
|
+
export async function sendReminderEmail(
|
|
220
|
+
payload: ReminderEmailPayload,
|
|
221
|
+
emailAdapter: EmailAdapter,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const { date, time } = formatDateTimeForEmail(payload.startsAt, payload.timezone);
|
|
224
|
+
|
|
225
|
+
const vars: EmailTemplateVars = {
|
|
226
|
+
bookingId: payload.bookingId,
|
|
227
|
+
eventTitle: payload.eventTitle,
|
|
228
|
+
providerName: payload.providerName,
|
|
229
|
+
customerName: payload.customerName,
|
|
230
|
+
customerEmail: payload.customerEmail,
|
|
231
|
+
date,
|
|
232
|
+
time,
|
|
233
|
+
duration: formatDurationForEmail(payload.startsAt, payload.endsAt),
|
|
234
|
+
timezone: payload.timezone,
|
|
235
|
+
location: payload.location,
|
|
236
|
+
managementUrl: payload.managementUrl,
|
|
237
|
+
unsubscribeUrl: payload.unsubscribeUrl,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const label =
|
|
241
|
+
payload.reminderHours >= 24
|
|
242
|
+
? `${payload.reminderHours / 24} day`
|
|
243
|
+
: `${payload.reminderHours} hour`;
|
|
244
|
+
|
|
245
|
+
await emailAdapter.send({
|
|
246
|
+
to: payload.customerEmail,
|
|
247
|
+
subject: `Reminder: ${payload.eventTitle} in ${label}${Number(label.split(" ")[0]) !== 1 ? "s" : ""}`,
|
|
248
|
+
html: interpolateTemplate(REMINDER_EMAIL_HTML, vars),
|
|
249
|
+
headers: buildEmailHeaders(payload.unsubscribeUrl),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Send cancellation notification emails to customer and provider.
|
|
255
|
+
*/
|
|
256
|
+
export async function sendCancellationEmail(
|
|
257
|
+
payload: CancellationEmailPayload,
|
|
258
|
+
emailAdapter: EmailAdapter,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
const { date, time } = formatDateTimeForEmail(payload.startsAt, payload.timezone);
|
|
261
|
+
|
|
262
|
+
const vars: EmailTemplateVars = {
|
|
263
|
+
bookingId: payload.bookingId,
|
|
264
|
+
eventTitle: payload.eventTitle,
|
|
265
|
+
providerName: payload.providerName,
|
|
266
|
+
customerName: payload.customerName,
|
|
267
|
+
customerEmail: payload.customerEmail,
|
|
268
|
+
date,
|
|
269
|
+
time,
|
|
270
|
+
duration: formatDurationForEmail(payload.startsAt, payload.endsAt),
|
|
271
|
+
timezone: payload.timezone,
|
|
272
|
+
location: payload.location,
|
|
273
|
+
unsubscribeUrl: payload.unsubscribeUrl,
|
|
274
|
+
cancelReason: payload.reason,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
await emailAdapter.send({
|
|
278
|
+
to: payload.customerEmail,
|
|
279
|
+
subject: `Booking Cancelled: ${payload.eventTitle}`,
|
|
280
|
+
html: interpolateTemplate(CANCELLATION_EMAIL_HTML, vars),
|
|
281
|
+
headers: buildEmailHeaders(payload.unsubscribeUrl),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await emailAdapter.send({
|
|
285
|
+
to: payload.providerEmail,
|
|
286
|
+
subject: `Booking Cancelled by ${payload.cancelledBy}: ${payload.customerName}`,
|
|
287
|
+
html: interpolateTemplate(CANCELLATION_EMAIL_HTML, vars),
|
|
288
|
+
headers: buildEmailHeaders(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Send reschedule notification emails to customer and provider.
|
|
294
|
+
*/
|
|
295
|
+
export async function sendRescheduleEmail(
|
|
296
|
+
payload: RescheduleEmailPayload,
|
|
297
|
+
emailAdapter: EmailAdapter,
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
const { date: newDate, time: newTime } = formatDateTimeForEmail(
|
|
300
|
+
payload.startsAt,
|
|
301
|
+
payload.timezone,
|
|
302
|
+
);
|
|
303
|
+
const { date: oldDate, time: oldTime } = formatDateTimeForEmail(
|
|
304
|
+
payload.oldStartsAt,
|
|
305
|
+
payload.timezone,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const vars: EmailTemplateVars = {
|
|
309
|
+
bookingId: payload.bookingId,
|
|
310
|
+
eventTitle: payload.eventTitle,
|
|
311
|
+
providerName: payload.providerName,
|
|
312
|
+
customerName: payload.customerName,
|
|
313
|
+
customerEmail: payload.customerEmail,
|
|
314
|
+
date: newDate,
|
|
315
|
+
time: newTime,
|
|
316
|
+
duration: formatDurationForEmail(payload.startsAt, payload.endsAt),
|
|
317
|
+
timezone: payload.timezone,
|
|
318
|
+
location: payload.location,
|
|
319
|
+
managementUrl: payload.managementUrl,
|
|
320
|
+
unsubscribeUrl: payload.unsubscribeUrl,
|
|
321
|
+
oldDate,
|
|
322
|
+
oldTime,
|
|
323
|
+
newDate,
|
|
324
|
+
newTime,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
await emailAdapter.send({
|
|
328
|
+
to: payload.customerEmail,
|
|
329
|
+
subject: `Booking Rescheduled: ${payload.eventTitle}`,
|
|
330
|
+
html: interpolateTemplate(RESCHEDULE_EMAIL_HTML, vars),
|
|
331
|
+
headers: buildEmailHeaders(payload.unsubscribeUrl),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await emailAdapter.send({
|
|
335
|
+
to: payload.providerEmail,
|
|
336
|
+
subject: `Booking Rescheduled: ${payload.customerName} — ${payload.eventTitle}`,
|
|
337
|
+
html: interpolateTemplate(RESCHEDULE_EMAIL_HTML, vars),
|
|
338
|
+
headers: buildEmailHeaders(),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Schedule the auto-rejection of a pending booking using the job adapter.
|
|
344
|
+
*
|
|
345
|
+
* Call this immediately after creating a booking that requires confirmation.
|
|
346
|
+
*
|
|
347
|
+
* @param bookingId - ID of the pending booking
|
|
348
|
+
* @param deadline - Date at which to auto-reject (from `getAutoRejectDeadline`)
|
|
349
|
+
* @param jobs - Your `JobAdapter` instance
|
|
350
|
+
* @returns The scheduled job ID (store it if you need to cancel on manual confirm/reject)
|
|
351
|
+
*/
|
|
352
|
+
export async function scheduleAutoReject(
|
|
353
|
+
bookingId: string,
|
|
354
|
+
deadline: Date,
|
|
355
|
+
jobs: JobAdapter,
|
|
356
|
+
): Promise<string> {
|
|
357
|
+
const payload: AutoRejectPendingPayload = { bookingId, actor: "system" };
|
|
358
|
+
return jobs.schedule(JOB_NAMES.AUTO_REJECT_PENDING, payload, deadline);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Sync a confirmed booking to the provider's connected calendar.
|
|
363
|
+
*/
|
|
364
|
+
export async function syncBookingToCalendar(
|
|
365
|
+
payload: CalendarSyncPayload,
|
|
366
|
+
calendarAdapter: CalendarAdapter,
|
|
367
|
+
): Promise<string | undefined> {
|
|
368
|
+
const result = await calendarAdapter.createEvent({
|
|
369
|
+
title: `${payload.eventTitle} — ${payload.customerName}`,
|
|
370
|
+
description: `Customer: ${payload.customerName} (${payload.customerEmail})`,
|
|
371
|
+
startsAt: new Date(payload.startsAt),
|
|
372
|
+
endsAt: new Date(payload.endsAt),
|
|
373
|
+
timezone: payload.timezone,
|
|
374
|
+
location: payload.location,
|
|
375
|
+
attendees: [payload.customerEmail],
|
|
376
|
+
});
|
|
377
|
+
return result.eventId;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Delete a calendar event when a booking is cancelled.
|
|
382
|
+
*/
|
|
383
|
+
export async function deleteBookingFromCalendar(
|
|
384
|
+
externalEventId: string,
|
|
385
|
+
calendarAdapter: CalendarAdapter,
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
await calendarAdapter.deleteEvent(externalEventId);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Internal helpers
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
function buildEmailHeaders(
|
|
395
|
+
unsubscribeUrl?: string,
|
|
396
|
+
): Record<string, string> | undefined {
|
|
397
|
+
if (!unsubscribeUrl) return undefined;
|
|
398
|
+
return {
|
|
399
|
+
"List-Unsubscribe": `<${unsubscribeUrl}>`,
|
|
400
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function generateBookingICS(
|
|
405
|
+
payload: NotificationBookingData,
|
|
406
|
+
): { filename: string; content: string; contentType: string } | null {
|
|
407
|
+
try {
|
|
408
|
+
const attachment = generateICSAttachment({
|
|
409
|
+
id: payload.bookingId,
|
|
410
|
+
title: payload.eventTitle,
|
|
411
|
+
startsAt: new Date(payload.startsAt),
|
|
412
|
+
endsAt: new Date(payload.endsAt),
|
|
413
|
+
location: payload.location,
|
|
414
|
+
description: `Booking with ${payload.providerName}`,
|
|
415
|
+
organizerEmail: payload.providerEmail,
|
|
416
|
+
attendeeEmail: payload.customerEmail,
|
|
417
|
+
});
|
|
418
|
+
return {
|
|
419
|
+
filename: attachment.filename,
|
|
420
|
+
content: attachment.content as string,
|
|
421
|
+
contentType: attachment.contentType ?? "text/calendar",
|
|
422
|
+
};
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export { JOB_NAMES };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BookingConflictError,
|
|
3
|
+
SerializationRetryExhaustedError,
|
|
4
|
+
} from "@thebookingkit/core";
|
|
5
|
+
|
|
6
|
+
/** Options for the serialization retry wrapper */
|
|
7
|
+
export interface SerializableRetryOptions {
|
|
8
|
+
/** Maximum number of retries on serialization failure. Default: 3 */
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
/** Base delay in milliseconds for exponential backoff. Default: 50 */
|
|
11
|
+
baseDelayMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Postgres SQLSTATE codes we handle:
|
|
16
|
+
* - 40001: serialization_failure (SERIALIZABLE transaction contention)
|
|
17
|
+
* - 23P01: exclusion_violation (EXCLUDE constraint — slot already taken)
|
|
18
|
+
*/
|
|
19
|
+
const SERIALIZATION_FAILURE = "40001";
|
|
20
|
+
const EXCLUSION_VIOLATION = "23P01";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if an error is a Postgres error with a specific code.
|
|
24
|
+
*/
|
|
25
|
+
function getPostgresErrorCode(error: unknown): string | undefined {
|
|
26
|
+
if (
|
|
27
|
+
error &&
|
|
28
|
+
typeof error === "object" &&
|
|
29
|
+
"code" in error &&
|
|
30
|
+
typeof (error as { code: unknown }).code === "string"
|
|
31
|
+
) {
|
|
32
|
+
return (error as { code: string }).code;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wraps a database operation in serialization retry logic.
|
|
39
|
+
*
|
|
40
|
+
* If the operation fails with SQLSTATE 40001 (serialization_failure),
|
|
41
|
+
* retries up to `maxRetries` times with jittered exponential backoff.
|
|
42
|
+
*
|
|
43
|
+
* If the operation fails with SQLSTATE 23P01 (exclusion_violation),
|
|
44
|
+
* immediately throws a `BookingConflictError` (no retry — the slot is taken).
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* const booking = await withSerializableRetry(
|
|
49
|
+
* () => db.transaction(async (tx) => {
|
|
50
|
+
* // insert booking in SERIALIZABLE isolation
|
|
51
|
+
* }),
|
|
52
|
+
* { maxRetries: 3 }
|
|
53
|
+
* );
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export async function withSerializableRetry<T>(
|
|
57
|
+
fn: () => Promise<T>,
|
|
58
|
+
options?: SerializableRetryOptions,
|
|
59
|
+
): Promise<T> {
|
|
60
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
61
|
+
const baseDelayMs = options?.baseDelayMs ?? 50;
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
64
|
+
try {
|
|
65
|
+
return await fn();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const code = getPostgresErrorCode(error);
|
|
68
|
+
|
|
69
|
+
// Exclusion violation — slot is taken, do not retry
|
|
70
|
+
if (code === EXCLUSION_VIOLATION) {
|
|
71
|
+
throw new BookingConflictError();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Serialization failure — retry with backoff
|
|
75
|
+
if (code === SERIALIZATION_FAILURE && attempt < maxRetries) {
|
|
76
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
77
|
+
const jitter = Math.random() * delay;
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// All retries exhausted for serialization failure
|
|
83
|
+
if (code === SERIALIZATION_FAILURE) {
|
|
84
|
+
throw new SerializationRetryExhaustedError(maxRetries);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Unknown error — rethrow as-is
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Should never reach here, but TypeScript needs it
|
|
93
|
+
throw new SerializationRetryExhaustedError(maxRetries);
|
|
94
|
+
}
|