@voyantjs/bookings 0.1.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/LICENSE +109 -0
- package/README.md +42 -0
- package/dist/availability-ref.d.ts +418 -0
- package/dist/availability-ref.d.ts.map +1 -0
- package/dist/availability-ref.js +28 -0
- package/dist/extensions/suppliers.d.ts +3 -0
- package/dist/extensions/suppliers.d.ts.map +1 -0
- package/dist/extensions/suppliers.js +103 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/pii.d.ts +29 -0
- package/dist/pii.d.ts.map +1 -0
- package/dist/pii.js +131 -0
- package/dist/products-ref.d.ts +1043 -0
- package/dist/products-ref.d.ts.map +1 -0
- package/dist/products-ref.js +76 -0
- package/dist/routes.d.ts +2171 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +659 -0
- package/dist/schema/travel-details.d.ts +179 -0
- package/dist/schema/travel-details.d.ts.map +1 -0
- package/dist/schema/travel-details.js +46 -0
- package/dist/schema.d.ts +3180 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +509 -0
- package/dist/service.d.ts +5000 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +2016 -0
- package/dist/tasks/expire-stale-holds.d.ts +12 -0
- package/dist/tasks/expire-stale-holds.d.ts.map +1 -0
- package/dist/tasks/expire-stale-holds.js +7 -0
- package/dist/tasks/index.d.ts +2 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +1 -0
- package/dist/transactions-ref.d.ts +2223 -0
- package/dist/transactions-ref.d.ts.map +1 -0
- package/dist/transactions-ref.js +147 -0
- package/dist/validation.d.ts +643 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +355 -0
- package/package.json +68 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,2016 @@
|
|
|
1
|
+
import { and, asc, desc, eq, ilike, inArray, lte, ne, or, sql } from "drizzle-orm";
|
|
2
|
+
import { availabilitySlotsRef } from "./availability-ref.js";
|
|
3
|
+
import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productDayServicesRef, productDaysRef, productOptionsRef, productTicketSettingsRef, productsRef, } from "./products-ref.js";
|
|
4
|
+
import { bookingActivityLog, bookingAllocations, bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingNotes, bookingParticipants, bookingRedemptionEvents, bookingSupplierStatuses, bookings, } from "./schema.js";
|
|
5
|
+
import { bookingTransactionDetailsRef, offerItemParticipantsRef, offerItemsRef, offerParticipantsRef, offersRef, orderItemParticipantsRef, orderItemsRef, orderParticipantsRef, ordersRef, } from "./transactions-ref.js";
|
|
6
|
+
const travelerParticipantTypes = ["traveler", "occupant"];
|
|
7
|
+
class BookingServiceError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message ?? code);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "BookingServiceError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function toTimestamp(value) {
|
|
16
|
+
return value ? new Date(value) : null;
|
|
17
|
+
}
|
|
18
|
+
function toDateValue(value) {
|
|
19
|
+
return value instanceof Date ? value : new Date(value);
|
|
20
|
+
}
|
|
21
|
+
function toDateValueOrNull(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return null;
|
|
24
|
+
return value instanceof Date ? value : new Date(value);
|
|
25
|
+
}
|
|
26
|
+
function toPassengerResponse(participant) {
|
|
27
|
+
return {
|
|
28
|
+
id: participant.id,
|
|
29
|
+
bookingId: participant.bookingId,
|
|
30
|
+
firstName: participant.firstName,
|
|
31
|
+
lastName: participant.lastName,
|
|
32
|
+
email: participant.email,
|
|
33
|
+
phone: participant.phone,
|
|
34
|
+
specialRequests: participant.specialRequests,
|
|
35
|
+
isLeadPassenger: participant.isPrimary,
|
|
36
|
+
createdAt: participant.createdAt,
|
|
37
|
+
updatedAt: participant.updatedAt,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function toCreateParticipantFromPassenger(data) {
|
|
41
|
+
return {
|
|
42
|
+
participantType: "traveler",
|
|
43
|
+
firstName: data.firstName,
|
|
44
|
+
lastName: data.lastName,
|
|
45
|
+
email: data.email ?? null,
|
|
46
|
+
phone: data.phone ?? null,
|
|
47
|
+
specialRequests: data.specialRequests ?? null,
|
|
48
|
+
isPrimary: data.isLeadPassenger ?? false,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function toUpdateParticipantFromPassenger(data) {
|
|
52
|
+
return {
|
|
53
|
+
firstName: data.firstName,
|
|
54
|
+
lastName: data.lastName,
|
|
55
|
+
email: data.email ?? null,
|
|
56
|
+
phone: data.phone ?? null,
|
|
57
|
+
specialRequests: data.specialRequests ?? null,
|
|
58
|
+
isPrimary: data.isLeadPassenger ?? undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function ensureParticipantFlags(db, bookingId, participantId, data) {
|
|
62
|
+
if (data.isPrimary) {
|
|
63
|
+
await db
|
|
64
|
+
.update(bookingParticipants)
|
|
65
|
+
.set({ isPrimary: false, updatedAt: new Date() })
|
|
66
|
+
.where(and(eq(bookingParticipants.bookingId, bookingId), ne(bookingParticipants.id, participantId)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function ensureBookingScopedLinks(db, bookingId, data) {
|
|
70
|
+
if (data.bookingItemId) {
|
|
71
|
+
const [item] = await db
|
|
72
|
+
.select({ id: bookingItems.id })
|
|
73
|
+
.from(bookingItems)
|
|
74
|
+
.where(and(eq(bookingItems.id, data.bookingItemId), eq(bookingItems.bookingId, bookingId)))
|
|
75
|
+
.limit(1);
|
|
76
|
+
if (!item) {
|
|
77
|
+
return { ok: false, reason: "booking_item_not_found" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (data.participantId) {
|
|
81
|
+
const [participant] = await db
|
|
82
|
+
.select({ id: bookingParticipants.id })
|
|
83
|
+
.from(bookingParticipants)
|
|
84
|
+
.where(and(eq(bookingParticipants.id, data.participantId), eq(bookingParticipants.bookingId, bookingId)))
|
|
85
|
+
.limit(1);
|
|
86
|
+
if (!participant) {
|
|
87
|
+
return { ok: false, reason: "participant_not_found" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
function deriveBookingDateRange(items) {
|
|
93
|
+
const dates = items
|
|
94
|
+
.flatMap((item) => [item.serviceDate, item.startsAt?.toISOString().slice(0, 10) ?? null])
|
|
95
|
+
.filter((value) => Boolean(value))
|
|
96
|
+
.sort();
|
|
97
|
+
return {
|
|
98
|
+
startDate: dates[0] ?? null,
|
|
99
|
+
endDate: dates[dates.length - 1] ?? null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function deriveBookingPax(participants, items) {
|
|
103
|
+
const pax = participants.filter((participant) => ["traveler", "occupant"].includes(participant.participantType)).length;
|
|
104
|
+
if (pax > 0) {
|
|
105
|
+
return pax;
|
|
106
|
+
}
|
|
107
|
+
return items
|
|
108
|
+
.filter((item) => item.itemType === "unit")
|
|
109
|
+
.reduce((sum, item) => sum + item.quantity, 0);
|
|
110
|
+
}
|
|
111
|
+
function getTransactionItemParticipantItemId(link) {
|
|
112
|
+
return "offerItemId" in link ? link.offerItemId : link.orderItemId;
|
|
113
|
+
}
|
|
114
|
+
function mapDeliveryFormatToFulfillment(format) {
|
|
115
|
+
switch (format) {
|
|
116
|
+
case "pdf":
|
|
117
|
+
return { fulfillmentType: "pdf", deliveryChannel: "download" };
|
|
118
|
+
case "qr_code":
|
|
119
|
+
return { fulfillmentType: "qr_code", deliveryChannel: "download" };
|
|
120
|
+
case "barcode":
|
|
121
|
+
return { fulfillmentType: "barcode", deliveryChannel: "download" };
|
|
122
|
+
case "mobile":
|
|
123
|
+
return { fulfillmentType: "mobile", deliveryChannel: "wallet" };
|
|
124
|
+
case "email":
|
|
125
|
+
return { fulfillmentType: "voucher", deliveryChannel: "email" };
|
|
126
|
+
case "ticket":
|
|
127
|
+
return { fulfillmentType: "ticket", deliveryChannel: "download" };
|
|
128
|
+
case "voucher":
|
|
129
|
+
default:
|
|
130
|
+
return { fulfillmentType: "voucher", deliveryChannel: "download" };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function getConvertProductData(db, data) {
|
|
134
|
+
const [product] = await db
|
|
135
|
+
.select()
|
|
136
|
+
.from(productsRef)
|
|
137
|
+
.where(eq(productsRef.id, data.productId))
|
|
138
|
+
.limit(1);
|
|
139
|
+
if (!product) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
let option = null;
|
|
143
|
+
if (data.optionId) {
|
|
144
|
+
const [selectedOption] = await db
|
|
145
|
+
.select()
|
|
146
|
+
.from(productOptionsRef)
|
|
147
|
+
.where(and(eq(productOptionsRef.id, data.optionId), eq(productOptionsRef.productId, product.id)))
|
|
148
|
+
.limit(1);
|
|
149
|
+
if (!selectedOption) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
option = selectedOption;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const [defaultOption] = await db
|
|
156
|
+
.select()
|
|
157
|
+
.from(productOptionsRef)
|
|
158
|
+
.where(eq(productOptionsRef.productId, product.id))
|
|
159
|
+
.orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder), asc(productOptionsRef.createdAt))
|
|
160
|
+
.limit(1);
|
|
161
|
+
option = defaultOption ?? null;
|
|
162
|
+
}
|
|
163
|
+
const days = await db
|
|
164
|
+
.select()
|
|
165
|
+
.from(productDaysRef)
|
|
166
|
+
.where(eq(productDaysRef.productId, product.id))
|
|
167
|
+
.orderBy(asc(productDaysRef.dayNumber));
|
|
168
|
+
const dayServices = days.length
|
|
169
|
+
? await db
|
|
170
|
+
.select({
|
|
171
|
+
supplierServiceId: productDayServicesRef.supplierServiceId,
|
|
172
|
+
name: productDayServicesRef.name,
|
|
173
|
+
costCurrency: productDayServicesRef.costCurrency,
|
|
174
|
+
costAmountCents: productDayServicesRef.costAmountCents,
|
|
175
|
+
})
|
|
176
|
+
.from(productDayServicesRef)
|
|
177
|
+
.where(sql `${productDayServicesRef.dayId} IN (
|
|
178
|
+
SELECT ${productDaysRef.id}
|
|
179
|
+
FROM ${productDaysRef}
|
|
180
|
+
WHERE ${productDaysRef.productId} = ${product.id}
|
|
181
|
+
)`)
|
|
182
|
+
.orderBy(asc(productDayServicesRef.sortOrder), asc(productDayServicesRef.id))
|
|
183
|
+
: [];
|
|
184
|
+
const units = option === null
|
|
185
|
+
? []
|
|
186
|
+
: await db
|
|
187
|
+
.select()
|
|
188
|
+
.from(optionUnitsRef)
|
|
189
|
+
.where(eq(optionUnitsRef.optionId, option.id))
|
|
190
|
+
.orderBy(asc(optionUnitsRef.sortOrder), asc(optionUnitsRef.createdAt));
|
|
191
|
+
return {
|
|
192
|
+
product: {
|
|
193
|
+
id: product.id,
|
|
194
|
+
name: product.name,
|
|
195
|
+
description: product.description,
|
|
196
|
+
sellCurrency: product.sellCurrency,
|
|
197
|
+
sellAmountCents: product.sellAmountCents,
|
|
198
|
+
costAmountCents: product.costAmountCents,
|
|
199
|
+
marginPercent: product.marginPercent,
|
|
200
|
+
startDate: product.startDate,
|
|
201
|
+
endDate: product.endDate,
|
|
202
|
+
pax: product.pax,
|
|
203
|
+
},
|
|
204
|
+
option: option ? { id: option.id, name: option.name } : null,
|
|
205
|
+
dayServices,
|
|
206
|
+
units: units.map((unit) => ({
|
|
207
|
+
id: unit.id,
|
|
208
|
+
name: unit.name,
|
|
209
|
+
description: unit.description,
|
|
210
|
+
unitType: unit.unitType,
|
|
211
|
+
isRequired: unit.isRequired,
|
|
212
|
+
minQuantity: unit.minQuantity,
|
|
213
|
+
sortOrder: unit.sortOrder,
|
|
214
|
+
})),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const VALID_BOOKING_TRANSITIONS = {
|
|
218
|
+
draft: ["on_hold", "confirmed", "cancelled"],
|
|
219
|
+
on_hold: ["confirmed", "expired", "cancelled"],
|
|
220
|
+
confirmed: ["in_progress", "cancelled"],
|
|
221
|
+
in_progress: ["completed", "cancelled"],
|
|
222
|
+
completed: [],
|
|
223
|
+
expired: [],
|
|
224
|
+
cancelled: [],
|
|
225
|
+
};
|
|
226
|
+
function isValidBookingTransition(from, to) {
|
|
227
|
+
return VALID_BOOKING_TRANSITIONS[from].includes(to);
|
|
228
|
+
}
|
|
229
|
+
function computeHoldExpiresAt(input) {
|
|
230
|
+
if (input.holdExpiresAt) {
|
|
231
|
+
return new Date(input.holdExpiresAt);
|
|
232
|
+
}
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const minutes = input.holdMinutes ?? 30;
|
|
235
|
+
return new Date(now + minutes * 60 * 1000);
|
|
236
|
+
}
|
|
237
|
+
function toBookingStatusTimestamps(status) {
|
|
238
|
+
const now = new Date();
|
|
239
|
+
return {
|
|
240
|
+
confirmedAt: status === "confirmed" ? now : undefined,
|
|
241
|
+
expiredAt: status === "expired" ? now : undefined,
|
|
242
|
+
cancelledAt: status === "cancelled" ? now : undefined,
|
|
243
|
+
completedAt: status === "completed" ? now : undefined,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async function lockAvailabilitySlot(db, slotId) {
|
|
247
|
+
const rows = await db.execute(sql `SELECT id, product_id, option_id, date_local, starts_at, ends_at, timezone, status, unlimited, remaining_pax
|
|
248
|
+
FROM ${availabilitySlotsRef}
|
|
249
|
+
WHERE ${availabilitySlotsRef.id} = ${slotId}
|
|
250
|
+
FOR UPDATE`);
|
|
251
|
+
const row = rows[0];
|
|
252
|
+
if (!row) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
...row,
|
|
257
|
+
starts_at: toDateValue(row.starts_at),
|
|
258
|
+
ends_at: toDateValueOrNull(row.ends_at),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
async function adjustSlotCapacity(db, slotId, delta) {
|
|
262
|
+
const locked = await lockAvailabilitySlot(db, slotId);
|
|
263
|
+
if (!locked) {
|
|
264
|
+
return { status: "slot_not_found" };
|
|
265
|
+
}
|
|
266
|
+
if (locked.status !== "open" && locked.status !== "sold_out") {
|
|
267
|
+
return { status: "slot_unavailable", slot: locked };
|
|
268
|
+
}
|
|
269
|
+
if (locked.unlimited) {
|
|
270
|
+
return { status: "ok", slot: locked, remainingPax: locked.remaining_pax };
|
|
271
|
+
}
|
|
272
|
+
const currentRemaining = locked.remaining_pax ?? 0;
|
|
273
|
+
const nextRemaining = currentRemaining + delta;
|
|
274
|
+
if (nextRemaining < 0) {
|
|
275
|
+
return { status: "insufficient_capacity", slot: locked, remainingPax: currentRemaining };
|
|
276
|
+
}
|
|
277
|
+
let nextStatus = locked.status;
|
|
278
|
+
if (nextRemaining === 0 && locked.status === "open") {
|
|
279
|
+
nextStatus = "sold_out";
|
|
280
|
+
}
|
|
281
|
+
else if (nextRemaining > 0 && locked.status === "sold_out") {
|
|
282
|
+
nextStatus = "open";
|
|
283
|
+
}
|
|
284
|
+
await db
|
|
285
|
+
.update(availabilitySlotsRef)
|
|
286
|
+
.set({
|
|
287
|
+
remainingPax: nextRemaining,
|
|
288
|
+
status: nextStatus,
|
|
289
|
+
updatedAt: new Date(),
|
|
290
|
+
})
|
|
291
|
+
.where(eq(availabilitySlotsRef.id, slotId));
|
|
292
|
+
return { status: "ok", slot: locked, remainingPax: nextRemaining };
|
|
293
|
+
}
|
|
294
|
+
async function releaseAllocationCapacity(db, allocation) {
|
|
295
|
+
if (!allocation.availabilitySlotId) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (allocation.status !== "held" && allocation.status !== "confirmed") {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
await adjustSlotCapacity(db, allocation.availabilitySlotId, allocation.quantity);
|
|
302
|
+
}
|
|
303
|
+
async function reserveBookingFromTransactionSource(db, source, data, userId) {
|
|
304
|
+
try {
|
|
305
|
+
return await db.transaction(async (tx) => {
|
|
306
|
+
const holdExpiresAt = computeHoldExpiresAt(data);
|
|
307
|
+
const dateRange = deriveBookingDateRange(source.items);
|
|
308
|
+
const pax = deriveBookingPax(source.participants, source.items);
|
|
309
|
+
const [booking] = await tx
|
|
310
|
+
.insert(bookings)
|
|
311
|
+
.values({
|
|
312
|
+
bookingNumber: data.bookingNumber,
|
|
313
|
+
status: "on_hold",
|
|
314
|
+
personId: source.personId,
|
|
315
|
+
organizationId: source.organizationId,
|
|
316
|
+
sourceType: data.sourceType,
|
|
317
|
+
sellCurrency: source.currency,
|
|
318
|
+
baseCurrency: source.baseCurrency,
|
|
319
|
+
sellAmountCents: source.totalAmountCents,
|
|
320
|
+
costAmountCents: source.costAmountCents,
|
|
321
|
+
startDate: dateRange.startDate,
|
|
322
|
+
endDate: dateRange.endDate,
|
|
323
|
+
pax: pax > 0 ? pax : null,
|
|
324
|
+
internalNotes: data.internalNotes ?? source.notes,
|
|
325
|
+
holdExpiresAt,
|
|
326
|
+
})
|
|
327
|
+
.returning();
|
|
328
|
+
if (!booking) {
|
|
329
|
+
throw new BookingServiceError("booking_create_failed");
|
|
330
|
+
}
|
|
331
|
+
const participantMap = new Map();
|
|
332
|
+
if (data.includeParticipants) {
|
|
333
|
+
for (const participant of source.participants) {
|
|
334
|
+
const [createdParticipant] = await tx
|
|
335
|
+
.insert(bookingParticipants)
|
|
336
|
+
.values({
|
|
337
|
+
bookingId: booking.id,
|
|
338
|
+
personId: participant.personId ?? null,
|
|
339
|
+
participantType: participant.participantType,
|
|
340
|
+
travelerCategory: participant.travelerCategory ?? null,
|
|
341
|
+
firstName: participant.firstName,
|
|
342
|
+
lastName: participant.lastName,
|
|
343
|
+
email: participant.email ?? null,
|
|
344
|
+
phone: participant.phone ?? null,
|
|
345
|
+
preferredLanguage: participant.preferredLanguage ?? null,
|
|
346
|
+
isPrimary: participant.isPrimary,
|
|
347
|
+
notes: participant.notes ?? null,
|
|
348
|
+
})
|
|
349
|
+
.returning();
|
|
350
|
+
if (!createdParticipant) {
|
|
351
|
+
throw new BookingServiceError("participant_create_failed");
|
|
352
|
+
}
|
|
353
|
+
participantMap.set(participant.id, createdParticipant.id);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const bookingItemMap = new Map();
|
|
357
|
+
for (const item of source.items) {
|
|
358
|
+
if (item.slotId) {
|
|
359
|
+
const capacity = await adjustSlotCapacity(tx, item.slotId, -item.quantity);
|
|
360
|
+
if (capacity.status === "slot_not_found") {
|
|
361
|
+
throw new BookingServiceError("slot_not_found");
|
|
362
|
+
}
|
|
363
|
+
if (capacity.status === "slot_unavailable") {
|
|
364
|
+
throw new BookingServiceError("slot_unavailable");
|
|
365
|
+
}
|
|
366
|
+
if (capacity.status === "insufficient_capacity") {
|
|
367
|
+
throw new BookingServiceError("insufficient_capacity");
|
|
368
|
+
}
|
|
369
|
+
const slot = capacity.slot;
|
|
370
|
+
if (item.productId && item.productId !== slot.product_id) {
|
|
371
|
+
throw new BookingServiceError("slot_product_mismatch");
|
|
372
|
+
}
|
|
373
|
+
if (item.optionId && item.optionId !== slot.option_id) {
|
|
374
|
+
throw new BookingServiceError("slot_option_mismatch");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const [bookingItem] = await tx
|
|
378
|
+
.insert(bookingItems)
|
|
379
|
+
.values({
|
|
380
|
+
bookingId: booking.id,
|
|
381
|
+
title: item.title,
|
|
382
|
+
description: item.description ?? null,
|
|
383
|
+
itemType: item.itemType,
|
|
384
|
+
status: "on_hold",
|
|
385
|
+
serviceDate: item.serviceDate ?? (item.slotId ? undefined : null),
|
|
386
|
+
startsAt: item.startsAt ?? null,
|
|
387
|
+
endsAt: item.endsAt ?? null,
|
|
388
|
+
quantity: item.quantity,
|
|
389
|
+
sellCurrency: item.sellCurrency,
|
|
390
|
+
unitSellAmountCents: item.unitSellAmountCents ?? null,
|
|
391
|
+
totalSellAmountCents: item.totalSellAmountCents ?? null,
|
|
392
|
+
costCurrency: item.costCurrency ?? null,
|
|
393
|
+
unitCostAmountCents: item.unitCostAmountCents ?? null,
|
|
394
|
+
totalCostAmountCents: item.totalCostAmountCents ?? null,
|
|
395
|
+
notes: item.notes ?? null,
|
|
396
|
+
productId: item.productId ?? null,
|
|
397
|
+
optionId: item.optionId ?? null,
|
|
398
|
+
optionUnitId: item.unitId ?? null,
|
|
399
|
+
sourceOfferId: source.offerId,
|
|
400
|
+
metadata: item.metadata ?? null,
|
|
401
|
+
})
|
|
402
|
+
.returning();
|
|
403
|
+
if (!bookingItem) {
|
|
404
|
+
throw new BookingServiceError("booking_item_create_failed");
|
|
405
|
+
}
|
|
406
|
+
bookingItemMap.set(item.id, bookingItem.id);
|
|
407
|
+
if (item.slotId) {
|
|
408
|
+
const [allocation] = await tx
|
|
409
|
+
.insert(bookingAllocations)
|
|
410
|
+
.values({
|
|
411
|
+
bookingId: booking.id,
|
|
412
|
+
bookingItemId: bookingItem.id,
|
|
413
|
+
productId: item.productId ?? null,
|
|
414
|
+
optionId: item.optionId ?? null,
|
|
415
|
+
optionUnitId: item.unitId ?? null,
|
|
416
|
+
availabilitySlotId: item.slotId,
|
|
417
|
+
quantity: item.quantity,
|
|
418
|
+
allocationType: "unit",
|
|
419
|
+
status: "held",
|
|
420
|
+
holdExpiresAt,
|
|
421
|
+
metadata: item.metadata ?? null,
|
|
422
|
+
})
|
|
423
|
+
.returning();
|
|
424
|
+
if (!allocation) {
|
|
425
|
+
throw new BookingServiceError("allocation_create_failed");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const link of source.itemParticipants) {
|
|
430
|
+
const sourceItemId = getTransactionItemParticipantItemId(link);
|
|
431
|
+
if (!sourceItemId) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const bookingItemId = bookingItemMap.get(sourceItemId);
|
|
435
|
+
const participantId = participantMap.get(link.participantId);
|
|
436
|
+
if (!bookingItemId || !participantId) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
await tx.insert(bookingItemParticipants).values({
|
|
440
|
+
bookingItemId,
|
|
441
|
+
participantId,
|
|
442
|
+
role: link.role,
|
|
443
|
+
isPrimary: link.isPrimary,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
await tx
|
|
447
|
+
.insert(bookingTransactionDetailsRef)
|
|
448
|
+
.values({
|
|
449
|
+
bookingId: booking.id,
|
|
450
|
+
offerId: source.offerId,
|
|
451
|
+
orderId: source.orderId,
|
|
452
|
+
createdAt: new Date(),
|
|
453
|
+
updatedAt: new Date(),
|
|
454
|
+
})
|
|
455
|
+
.onConflictDoUpdate({
|
|
456
|
+
target: bookingTransactionDetailsRef.bookingId,
|
|
457
|
+
set: {
|
|
458
|
+
offerId: source.offerId,
|
|
459
|
+
orderId: source.orderId,
|
|
460
|
+
updatedAt: new Date(),
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
await tx.insert(bookingActivityLog).values({
|
|
464
|
+
bookingId: booking.id,
|
|
465
|
+
actorId: userId ?? "system",
|
|
466
|
+
activityType: "booking_reserved",
|
|
467
|
+
description: `Booking ${booking.bookingNumber} reserved from ${source.kind} ${source.sourceId}`,
|
|
468
|
+
metadata: {
|
|
469
|
+
sourceKind: source.kind,
|
|
470
|
+
sourceId: source.sourceId,
|
|
471
|
+
offerId: source.offerId,
|
|
472
|
+
orderId: source.orderId,
|
|
473
|
+
holdExpiresAt: holdExpiresAt.toISOString(),
|
|
474
|
+
itemCount: source.items.length,
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
if (data.note) {
|
|
478
|
+
await tx.insert(bookingNotes).values({
|
|
479
|
+
bookingId: booking.id,
|
|
480
|
+
authorId: userId ?? "system",
|
|
481
|
+
content: data.note,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return { status: "ok", booking };
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
if (error instanceof BookingServiceError) {
|
|
489
|
+
return { status: error.code };
|
|
490
|
+
}
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function getBookingTransactionLink(db, bookingId) {
|
|
495
|
+
const [link] = await db
|
|
496
|
+
.select()
|
|
497
|
+
.from(bookingTransactionDetailsRef)
|
|
498
|
+
.where(eq(bookingTransactionDetailsRef.bookingId, bookingId))
|
|
499
|
+
.limit(1);
|
|
500
|
+
return link ?? null;
|
|
501
|
+
}
|
|
502
|
+
async function syncTransactionOnBookingConfirmed(db, bookingId) {
|
|
503
|
+
const link = await getBookingTransactionLink(db, bookingId);
|
|
504
|
+
if (!link) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const now = new Date();
|
|
508
|
+
if (link.orderId) {
|
|
509
|
+
await db
|
|
510
|
+
.update(ordersRef)
|
|
511
|
+
.set({
|
|
512
|
+
status: "confirmed",
|
|
513
|
+
confirmedAt: now,
|
|
514
|
+
updatedAt: now,
|
|
515
|
+
})
|
|
516
|
+
.where(eq(ordersRef.id, link.orderId));
|
|
517
|
+
}
|
|
518
|
+
if (link.offerId) {
|
|
519
|
+
await db
|
|
520
|
+
.update(offersRef)
|
|
521
|
+
.set({
|
|
522
|
+
status: "converted",
|
|
523
|
+
acceptedAt: now,
|
|
524
|
+
convertedAt: now,
|
|
525
|
+
updatedAt: now,
|
|
526
|
+
})
|
|
527
|
+
.where(eq(offersRef.id, link.offerId));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async function syncTransactionOnBookingExpired(db, bookingId) {
|
|
531
|
+
const link = await getBookingTransactionLink(db, bookingId);
|
|
532
|
+
if (!link?.orderId) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const now = new Date();
|
|
536
|
+
await db
|
|
537
|
+
.update(ordersRef)
|
|
538
|
+
.set({
|
|
539
|
+
status: "expired",
|
|
540
|
+
expiresAt: now,
|
|
541
|
+
updatedAt: now,
|
|
542
|
+
})
|
|
543
|
+
.where(eq(ordersRef.id, link.orderId));
|
|
544
|
+
}
|
|
545
|
+
async function syncTransactionOnBookingCancelled(db, bookingId) {
|
|
546
|
+
const link = await getBookingTransactionLink(db, bookingId);
|
|
547
|
+
if (!link?.orderId) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const now = new Date();
|
|
551
|
+
await db
|
|
552
|
+
.update(ordersRef)
|
|
553
|
+
.set({
|
|
554
|
+
status: "cancelled",
|
|
555
|
+
cancelledAt: now,
|
|
556
|
+
updatedAt: now,
|
|
557
|
+
})
|
|
558
|
+
.where(eq(ordersRef.id, link.orderId));
|
|
559
|
+
}
|
|
560
|
+
async function syncTransactionOnBookingRedeemed(db, bookingId) {
|
|
561
|
+
const link = await getBookingTransactionLink(db, bookingId);
|
|
562
|
+
if (!link?.orderId) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
await db
|
|
566
|
+
.update(ordersRef)
|
|
567
|
+
.set({
|
|
568
|
+
status: "fulfilled",
|
|
569
|
+
updatedAt: new Date(),
|
|
570
|
+
})
|
|
571
|
+
.where(eq(ordersRef.id, link.orderId));
|
|
572
|
+
}
|
|
573
|
+
async function autoIssueFulfillmentsForBooking(db, bookingId, userId) {
|
|
574
|
+
const [booking] = await db
|
|
575
|
+
.select({
|
|
576
|
+
id: bookings.id,
|
|
577
|
+
bookingNumber: bookings.bookingNumber,
|
|
578
|
+
})
|
|
579
|
+
.from(bookings)
|
|
580
|
+
.where(eq(bookings.id, bookingId))
|
|
581
|
+
.limit(1);
|
|
582
|
+
if (!booking) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const existingFulfillment = await db
|
|
586
|
+
.select({ id: bookingFulfillments.id })
|
|
587
|
+
.from(bookingFulfillments)
|
|
588
|
+
.where(eq(bookingFulfillments.bookingId, bookingId))
|
|
589
|
+
.limit(1);
|
|
590
|
+
if (existingFulfillment.length > 0) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const items = await db
|
|
594
|
+
.select()
|
|
595
|
+
.from(bookingItems)
|
|
596
|
+
.where(and(eq(bookingItems.bookingId, bookingId), sql `${bookingItems.productId} IS NOT NULL`))
|
|
597
|
+
.orderBy(asc(bookingItems.createdAt));
|
|
598
|
+
if (items.length === 0) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const productIds = [...new Set(items.map((item) => item.productId).filter((value) => Boolean(value)))];
|
|
602
|
+
if (productIds.length === 0) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const settings = await db
|
|
606
|
+
.select()
|
|
607
|
+
.from(productTicketSettingsRef)
|
|
608
|
+
.where(inArray(productTicketSettingsRef.productId, productIds));
|
|
609
|
+
const settingsByProductId = new Map(settings.map((setting) => [setting.productId, setting]));
|
|
610
|
+
const travelerParticipants = await db
|
|
611
|
+
.select()
|
|
612
|
+
.from(bookingParticipants)
|
|
613
|
+
.where(and(eq(bookingParticipants.bookingId, bookingId), or(eq(bookingParticipants.participantType, "traveler"), eq(bookingParticipants.participantType, "occupant"))))
|
|
614
|
+
.orderBy(desc(bookingParticipants.isPrimary), asc(bookingParticipants.createdAt));
|
|
615
|
+
const participantLinks = await db
|
|
616
|
+
.select()
|
|
617
|
+
.from(bookingItemParticipants)
|
|
618
|
+
.where(sql `${bookingItemParticipants.bookingItemId} IN (
|
|
619
|
+
SELECT ${bookingItems.id}
|
|
620
|
+
FROM ${bookingItems}
|
|
621
|
+
WHERE ${bookingItems.bookingId} = ${bookingId}
|
|
622
|
+
)`);
|
|
623
|
+
const participantLinksByItemId = new Map();
|
|
624
|
+
for (const link of participantLinks) {
|
|
625
|
+
const links = participantLinksByItemId.get(link.bookingItemId) ?? [];
|
|
626
|
+
links.push(link);
|
|
627
|
+
participantLinksByItemId.set(link.bookingItemId, links);
|
|
628
|
+
}
|
|
629
|
+
const fulfillmentsToInsert = [];
|
|
630
|
+
const now = new Date();
|
|
631
|
+
for (const item of items) {
|
|
632
|
+
const productId = item.productId;
|
|
633
|
+
if (!productId) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const setting = settingsByProductId.get(productId);
|
|
637
|
+
if (!setting || setting.fulfillmentMode === "none" || setting.defaultDeliveryFormat === "none") {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const delivery = mapDeliveryFormatToFulfillment(setting.defaultDeliveryFormat);
|
|
641
|
+
const payloadBase = {
|
|
642
|
+
bookingId,
|
|
643
|
+
bookingNumber: booking.bookingNumber,
|
|
644
|
+
productId,
|
|
645
|
+
bookingItemId: item.id,
|
|
646
|
+
};
|
|
647
|
+
if (setting.fulfillmentMode === "per_booking") {
|
|
648
|
+
if (fulfillmentsToInsert.some((row) => row.bookingItemId === item.id || row.bookingItemId === null)) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
fulfillmentsToInsert.push({
|
|
652
|
+
bookingId,
|
|
653
|
+
bookingItemId: item.id,
|
|
654
|
+
participantId: null,
|
|
655
|
+
fulfillmentType: delivery.fulfillmentType,
|
|
656
|
+
deliveryChannel: delivery.deliveryChannel,
|
|
657
|
+
status: "issued",
|
|
658
|
+
payload: { ...payloadBase, scope: "booking" },
|
|
659
|
+
issuedAt: now,
|
|
660
|
+
});
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (setting.fulfillmentMode === "per_item") {
|
|
664
|
+
fulfillmentsToInsert.push({
|
|
665
|
+
bookingId,
|
|
666
|
+
bookingItemId: item.id,
|
|
667
|
+
participantId: null,
|
|
668
|
+
fulfillmentType: delivery.fulfillmentType,
|
|
669
|
+
deliveryChannel: delivery.deliveryChannel,
|
|
670
|
+
status: "issued",
|
|
671
|
+
payload: { ...payloadBase, scope: "item" },
|
|
672
|
+
issuedAt: now,
|
|
673
|
+
});
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const linkedParticipants = participantLinksByItemId
|
|
677
|
+
.get(item.id)
|
|
678
|
+
?.map((link) => travelerParticipants.find((participant) => participant.id === link.participantId))
|
|
679
|
+
.filter((participant) => Boolean(participant)) ??
|
|
680
|
+
[];
|
|
681
|
+
const participantsForItem = linkedParticipants.length > 0 ? linkedParticipants : travelerParticipants;
|
|
682
|
+
for (const participant of participantsForItem) {
|
|
683
|
+
fulfillmentsToInsert.push({
|
|
684
|
+
bookingId,
|
|
685
|
+
bookingItemId: item.id,
|
|
686
|
+
participantId: participant.id,
|
|
687
|
+
fulfillmentType: delivery.fulfillmentType,
|
|
688
|
+
deliveryChannel: delivery.deliveryChannel,
|
|
689
|
+
status: "issued",
|
|
690
|
+
payload: {
|
|
691
|
+
...payloadBase,
|
|
692
|
+
participantId: participant.id,
|
|
693
|
+
scope: "participant",
|
|
694
|
+
},
|
|
695
|
+
issuedAt: now,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (fulfillmentsToInsert.length === 0) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
await db.insert(bookingFulfillments).values(fulfillmentsToInsert);
|
|
703
|
+
await db.insert(bookingActivityLog).values({
|
|
704
|
+
bookingId,
|
|
705
|
+
actorId: userId ?? "system",
|
|
706
|
+
activityType: "fulfillment_issued",
|
|
707
|
+
description: `${fulfillmentsToInsert.length} fulfillment artifact(s) issued automatically`,
|
|
708
|
+
metadata: { count: fulfillmentsToInsert.length },
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
export const bookingsService = {
|
|
712
|
+
async listBookings(db, query) {
|
|
713
|
+
const conditions = [];
|
|
714
|
+
if (query.status) {
|
|
715
|
+
conditions.push(eq(bookings.status, query.status));
|
|
716
|
+
}
|
|
717
|
+
if (query.search) {
|
|
718
|
+
const term = `%${query.search}%`;
|
|
719
|
+
conditions.push(or(ilike(bookings.bookingNumber, term), ilike(bookings.internalNotes, term)));
|
|
720
|
+
}
|
|
721
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
722
|
+
const [rows, countResult] = await Promise.all([
|
|
723
|
+
db
|
|
724
|
+
.select()
|
|
725
|
+
.from(bookings)
|
|
726
|
+
.where(where)
|
|
727
|
+
.limit(query.limit)
|
|
728
|
+
.offset(query.offset)
|
|
729
|
+
.orderBy(desc(bookings.createdAt)),
|
|
730
|
+
db.select({ count: sql `count(*)::int` }).from(bookings).where(where),
|
|
731
|
+
]);
|
|
732
|
+
return {
|
|
733
|
+
data: rows,
|
|
734
|
+
total: countResult[0]?.count ?? 0,
|
|
735
|
+
limit: query.limit,
|
|
736
|
+
offset: query.offset,
|
|
737
|
+
};
|
|
738
|
+
},
|
|
739
|
+
async convertProductToBooking(db, data, productData, userId) {
|
|
740
|
+
const { product, option, dayServices, units } = productData;
|
|
741
|
+
const [booking] = await db
|
|
742
|
+
.insert(bookings)
|
|
743
|
+
.values({
|
|
744
|
+
bookingNumber: data.bookingNumber,
|
|
745
|
+
status: "draft",
|
|
746
|
+
personId: data.personId ?? null,
|
|
747
|
+
organizationId: data.organizationId ?? null,
|
|
748
|
+
sellCurrency: product.sellCurrency,
|
|
749
|
+
sellAmountCents: product.sellAmountCents,
|
|
750
|
+
costAmountCents: product.costAmountCents,
|
|
751
|
+
marginPercent: product.marginPercent,
|
|
752
|
+
startDate: product.startDate,
|
|
753
|
+
endDate: product.endDate,
|
|
754
|
+
pax: product.pax,
|
|
755
|
+
internalNotes: data.internalNotes ?? null,
|
|
756
|
+
})
|
|
757
|
+
.returning();
|
|
758
|
+
if (!booking) {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
if (dayServices.length > 0) {
|
|
762
|
+
await db.insert(bookingSupplierStatuses).values(dayServices.map((service) => ({
|
|
763
|
+
bookingId: booking.id,
|
|
764
|
+
supplierServiceId: service.supplierServiceId,
|
|
765
|
+
serviceName: service.name,
|
|
766
|
+
status: "pending",
|
|
767
|
+
costCurrency: service.costCurrency,
|
|
768
|
+
costAmountCents: service.costAmountCents,
|
|
769
|
+
})));
|
|
770
|
+
}
|
|
771
|
+
const selectedUnits = option === null ? [] : units;
|
|
772
|
+
const unitsToSeed = selectedUnits.filter((unit) => unit.isRequired).length > 0
|
|
773
|
+
? selectedUnits.filter((unit) => unit.isRequired)
|
|
774
|
+
: selectedUnits.length === 1
|
|
775
|
+
? selectedUnits
|
|
776
|
+
: [];
|
|
777
|
+
const itemRows = unitsToSeed.length > 0
|
|
778
|
+
? unitsToSeed.map((unit, index) => {
|
|
779
|
+
const quantity = unit.unitType === "person" && product.pax
|
|
780
|
+
? product.pax
|
|
781
|
+
: unit.minQuantity && unit.minQuantity > 0
|
|
782
|
+
? unit.minQuantity
|
|
783
|
+
: 1;
|
|
784
|
+
const singleSeedItem = unitsToSeed.length === 1 && index === 0;
|
|
785
|
+
return {
|
|
786
|
+
bookingId: booking.id,
|
|
787
|
+
title: unit.name,
|
|
788
|
+
description: unit.description,
|
|
789
|
+
itemType: "unit",
|
|
790
|
+
status: "draft",
|
|
791
|
+
quantity,
|
|
792
|
+
sellCurrency: product.sellCurrency,
|
|
793
|
+
unitSellAmountCents: singleSeedItem &&
|
|
794
|
+
product.sellAmountCents !== null &&
|
|
795
|
+
product.sellAmountCents !== undefined
|
|
796
|
+
? Math.floor(product.sellAmountCents / quantity)
|
|
797
|
+
: null,
|
|
798
|
+
totalSellAmountCents: singleSeedItem ? (product.sellAmountCents ?? null) : null,
|
|
799
|
+
costCurrency: singleSeedItem ? product.sellCurrency : null,
|
|
800
|
+
unitCostAmountCents: singleSeedItem &&
|
|
801
|
+
product.costAmountCents !== null &&
|
|
802
|
+
product.costAmountCents !== undefined
|
|
803
|
+
? Math.floor(product.costAmountCents / quantity)
|
|
804
|
+
: null,
|
|
805
|
+
totalCostAmountCents: singleSeedItem ? (product.costAmountCents ?? null) : null,
|
|
806
|
+
productId: product.id,
|
|
807
|
+
optionId: option?.id ?? null,
|
|
808
|
+
optionUnitId: unit.id,
|
|
809
|
+
};
|
|
810
|
+
})
|
|
811
|
+
: [
|
|
812
|
+
{
|
|
813
|
+
bookingId: booking.id,
|
|
814
|
+
title: option?.name ?? product.name,
|
|
815
|
+
description: product.description,
|
|
816
|
+
itemType: "unit",
|
|
817
|
+
status: "draft",
|
|
818
|
+
quantity: 1,
|
|
819
|
+
sellCurrency: product.sellCurrency,
|
|
820
|
+
unitSellAmountCents: product.sellAmountCents ?? null,
|
|
821
|
+
totalSellAmountCents: product.sellAmountCents ?? null,
|
|
822
|
+
costCurrency: product.sellCurrency,
|
|
823
|
+
unitCostAmountCents: product.costAmountCents ?? null,
|
|
824
|
+
totalCostAmountCents: product.costAmountCents ?? null,
|
|
825
|
+
productId: product.id,
|
|
826
|
+
optionId: option?.id ?? null,
|
|
827
|
+
optionUnitId: null,
|
|
828
|
+
},
|
|
829
|
+
];
|
|
830
|
+
const insertedItems = await db.insert(bookingItems).values(itemRows).returning();
|
|
831
|
+
await db
|
|
832
|
+
.insert(bookingProductDetailsRef)
|
|
833
|
+
.values({
|
|
834
|
+
bookingId: booking.id,
|
|
835
|
+
productId: product.id,
|
|
836
|
+
optionId: option?.id ?? null,
|
|
837
|
+
})
|
|
838
|
+
.onConflictDoUpdate({
|
|
839
|
+
target: bookingProductDetailsRef.bookingId,
|
|
840
|
+
set: {
|
|
841
|
+
productId: product.id,
|
|
842
|
+
optionId: option?.id ?? null,
|
|
843
|
+
updatedAt: new Date(),
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
if (insertedItems.length > 0) {
|
|
847
|
+
await db.insert(bookingItemProductDetailsRef).values(insertedItems.map((item) => ({
|
|
848
|
+
bookingItemId: item.id,
|
|
849
|
+
productId: item.productId ?? null,
|
|
850
|
+
optionId: item.optionId ?? null,
|
|
851
|
+
unitId: item.optionUnitId ?? null,
|
|
852
|
+
supplierServiceId: null,
|
|
853
|
+
})));
|
|
854
|
+
}
|
|
855
|
+
await db.insert(bookingActivityLog).values({
|
|
856
|
+
bookingId: booking.id,
|
|
857
|
+
actorId: userId ?? "system",
|
|
858
|
+
activityType: "booking_converted",
|
|
859
|
+
description: `Booking converted from product "${product.name}"`,
|
|
860
|
+
metadata: { productId: product.id, productName: product.name, optionId: option?.id ?? null },
|
|
861
|
+
});
|
|
862
|
+
return booking;
|
|
863
|
+
},
|
|
864
|
+
async getBookingById(db, id) {
|
|
865
|
+
const [row] = await db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
|
|
866
|
+
return row ?? null;
|
|
867
|
+
},
|
|
868
|
+
async createBookingFromProduct(db, data, userId) {
|
|
869
|
+
const productData = await getConvertProductData(db, data);
|
|
870
|
+
if (!productData) {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
return this.convertProductToBooking(db, data, productData, userId);
|
|
874
|
+
},
|
|
875
|
+
listAllocations(db, bookingId) {
|
|
876
|
+
return db
|
|
877
|
+
.select()
|
|
878
|
+
.from(bookingAllocations)
|
|
879
|
+
.where(eq(bookingAllocations.bookingId, bookingId))
|
|
880
|
+
.orderBy(asc(bookingAllocations.createdAt));
|
|
881
|
+
},
|
|
882
|
+
async reserveBookingFromOffer(db, offerId, data, userId) {
|
|
883
|
+
const [offer] = await db.select().from(offersRef).where(eq(offersRef.id, offerId)).limit(1);
|
|
884
|
+
if (!offer) {
|
|
885
|
+
return { status: "not_found" };
|
|
886
|
+
}
|
|
887
|
+
const [participants, items, itemParticipants] = await Promise.all([
|
|
888
|
+
db
|
|
889
|
+
.select()
|
|
890
|
+
.from(offerParticipantsRef)
|
|
891
|
+
.where(eq(offerParticipantsRef.offerId, offerId))
|
|
892
|
+
.orderBy(asc(offerParticipantsRef.createdAt)),
|
|
893
|
+
db
|
|
894
|
+
.select()
|
|
895
|
+
.from(offerItemsRef)
|
|
896
|
+
.where(eq(offerItemsRef.offerId, offerId))
|
|
897
|
+
.orderBy(asc(offerItemsRef.createdAt)),
|
|
898
|
+
db
|
|
899
|
+
.select()
|
|
900
|
+
.from(offerItemParticipantsRef)
|
|
901
|
+
.where(sql `${offerItemParticipantsRef.offerItemId} IN (
|
|
902
|
+
SELECT ${offerItemsRef.id}
|
|
903
|
+
FROM ${offerItemsRef}
|
|
904
|
+
WHERE ${offerItemsRef.offerId} = ${offerId}
|
|
905
|
+
)`)
|
|
906
|
+
.orderBy(asc(offerItemParticipantsRef.createdAt)),
|
|
907
|
+
]);
|
|
908
|
+
return reserveBookingFromTransactionSource(db, {
|
|
909
|
+
kind: "offer",
|
|
910
|
+
sourceId: offerId,
|
|
911
|
+
offerId: offer.id,
|
|
912
|
+
orderId: null,
|
|
913
|
+
personId: offer.personId ?? null,
|
|
914
|
+
organizationId: offer.organizationId ?? null,
|
|
915
|
+
currency: offer.currency,
|
|
916
|
+
baseCurrency: offer.baseCurrency ?? null,
|
|
917
|
+
totalAmountCents: offer.totalAmountCents ?? null,
|
|
918
|
+
costAmountCents: offer.costAmountCents ?? null,
|
|
919
|
+
notes: offer.notes ?? null,
|
|
920
|
+
participants,
|
|
921
|
+
items,
|
|
922
|
+
itemParticipants,
|
|
923
|
+
}, data, userId);
|
|
924
|
+
},
|
|
925
|
+
async reserveBookingFromOrder(db, orderId, data, userId) {
|
|
926
|
+
const [order] = await db.select().from(ordersRef).where(eq(ordersRef.id, orderId)).limit(1);
|
|
927
|
+
if (!order) {
|
|
928
|
+
return { status: "not_found" };
|
|
929
|
+
}
|
|
930
|
+
const [participants, items, itemParticipants] = await Promise.all([
|
|
931
|
+
db
|
|
932
|
+
.select()
|
|
933
|
+
.from(orderParticipantsRef)
|
|
934
|
+
.where(eq(orderParticipantsRef.orderId, orderId))
|
|
935
|
+
.orderBy(asc(orderParticipantsRef.createdAt)),
|
|
936
|
+
db
|
|
937
|
+
.select()
|
|
938
|
+
.from(orderItemsRef)
|
|
939
|
+
.where(eq(orderItemsRef.orderId, orderId))
|
|
940
|
+
.orderBy(asc(orderItemsRef.createdAt)),
|
|
941
|
+
db
|
|
942
|
+
.select()
|
|
943
|
+
.from(orderItemParticipantsRef)
|
|
944
|
+
.where(sql `${orderItemParticipantsRef.orderItemId} IN (
|
|
945
|
+
SELECT ${orderItemsRef.id}
|
|
946
|
+
FROM ${orderItemsRef}
|
|
947
|
+
WHERE ${orderItemsRef.orderId} = ${orderId}
|
|
948
|
+
)`)
|
|
949
|
+
.orderBy(asc(orderItemParticipantsRef.createdAt)),
|
|
950
|
+
]);
|
|
951
|
+
return reserveBookingFromTransactionSource(db, {
|
|
952
|
+
kind: "order",
|
|
953
|
+
sourceId: orderId,
|
|
954
|
+
offerId: order.offerId ?? null,
|
|
955
|
+
orderId: order.id,
|
|
956
|
+
personId: order.personId ?? null,
|
|
957
|
+
organizationId: order.organizationId ?? null,
|
|
958
|
+
currency: order.currency,
|
|
959
|
+
baseCurrency: order.baseCurrency ?? null,
|
|
960
|
+
totalAmountCents: order.totalAmountCents ?? null,
|
|
961
|
+
costAmountCents: order.costAmountCents ?? null,
|
|
962
|
+
notes: order.notes ?? null,
|
|
963
|
+
participants,
|
|
964
|
+
items,
|
|
965
|
+
itemParticipants,
|
|
966
|
+
}, data, userId);
|
|
967
|
+
},
|
|
968
|
+
async reserveBooking(db, data, userId) {
|
|
969
|
+
try {
|
|
970
|
+
return await db.transaction(async (tx) => {
|
|
971
|
+
const holdExpiresAt = computeHoldExpiresAt(data);
|
|
972
|
+
const [booking] = await tx
|
|
973
|
+
.insert(bookings)
|
|
974
|
+
.values({
|
|
975
|
+
bookingNumber: data.bookingNumber,
|
|
976
|
+
status: "on_hold",
|
|
977
|
+
personId: data.personId ?? null,
|
|
978
|
+
organizationId: data.organizationId ?? null,
|
|
979
|
+
sourceType: data.sourceType,
|
|
980
|
+
externalBookingRef: data.externalBookingRef ?? null,
|
|
981
|
+
communicationLanguage: data.communicationLanguage ?? null,
|
|
982
|
+
sellCurrency: data.sellCurrency,
|
|
983
|
+
baseCurrency: data.baseCurrency ?? null,
|
|
984
|
+
sellAmountCents: data.sellAmountCents ?? null,
|
|
985
|
+
baseSellAmountCents: data.baseSellAmountCents ?? null,
|
|
986
|
+
costAmountCents: data.costAmountCents ?? null,
|
|
987
|
+
baseCostAmountCents: data.baseCostAmountCents ?? null,
|
|
988
|
+
marginPercent: data.marginPercent ?? null,
|
|
989
|
+
startDate: data.startDate ?? null,
|
|
990
|
+
endDate: data.endDate ?? null,
|
|
991
|
+
pax: data.pax ?? null,
|
|
992
|
+
internalNotes: data.internalNotes ?? null,
|
|
993
|
+
holdExpiresAt,
|
|
994
|
+
})
|
|
995
|
+
.returning();
|
|
996
|
+
if (!booking) {
|
|
997
|
+
throw new BookingServiceError("booking_create_failed");
|
|
998
|
+
}
|
|
999
|
+
for (const item of data.items) {
|
|
1000
|
+
const capacity = await adjustSlotCapacity(tx, item.availabilitySlotId, -item.quantity);
|
|
1001
|
+
if (capacity.status === "slot_not_found") {
|
|
1002
|
+
throw new BookingServiceError("slot_not_found");
|
|
1003
|
+
}
|
|
1004
|
+
if (capacity.status === "slot_unavailable") {
|
|
1005
|
+
throw new BookingServiceError("slot_unavailable");
|
|
1006
|
+
}
|
|
1007
|
+
if (capacity.status === "insufficient_capacity") {
|
|
1008
|
+
throw new BookingServiceError("insufficient_capacity");
|
|
1009
|
+
}
|
|
1010
|
+
const slot = capacity.slot;
|
|
1011
|
+
if (item.productId && item.productId !== slot.product_id) {
|
|
1012
|
+
throw new BookingServiceError("slot_product_mismatch");
|
|
1013
|
+
}
|
|
1014
|
+
if (item.optionId && item.optionId !== slot.option_id) {
|
|
1015
|
+
throw new BookingServiceError("slot_option_mismatch");
|
|
1016
|
+
}
|
|
1017
|
+
const [bookingItem] = await tx
|
|
1018
|
+
.insert(bookingItems)
|
|
1019
|
+
.values({
|
|
1020
|
+
bookingId: booking.id,
|
|
1021
|
+
title: item.title,
|
|
1022
|
+
description: item.description ?? null,
|
|
1023
|
+
itemType: item.itemType,
|
|
1024
|
+
status: "on_hold",
|
|
1025
|
+
serviceDate: slot.date_local,
|
|
1026
|
+
startsAt: slot.starts_at,
|
|
1027
|
+
endsAt: slot.ends_at,
|
|
1028
|
+
quantity: item.quantity,
|
|
1029
|
+
sellCurrency: item.sellCurrency ?? booking.sellCurrency,
|
|
1030
|
+
unitSellAmountCents: item.unitSellAmountCents ?? null,
|
|
1031
|
+
totalSellAmountCents: item.totalSellAmountCents ?? null,
|
|
1032
|
+
costCurrency: item.costCurrency ?? null,
|
|
1033
|
+
unitCostAmountCents: item.unitCostAmountCents ?? null,
|
|
1034
|
+
totalCostAmountCents: item.totalCostAmountCents ?? null,
|
|
1035
|
+
notes: item.notes ?? null,
|
|
1036
|
+
productId: item.productId ?? slot.product_id,
|
|
1037
|
+
optionId: item.optionId ?? slot.option_id,
|
|
1038
|
+
optionUnitId: item.optionUnitId ?? null,
|
|
1039
|
+
pricingCategoryId: item.pricingCategoryId ?? null,
|
|
1040
|
+
sourceSnapshotId: item.sourceSnapshotId ?? null,
|
|
1041
|
+
sourceOfferId: item.sourceOfferId ?? null,
|
|
1042
|
+
metadata: item.metadata ?? null,
|
|
1043
|
+
})
|
|
1044
|
+
.returning();
|
|
1045
|
+
if (!bookingItem) {
|
|
1046
|
+
throw new BookingServiceError("booking_item_create_failed");
|
|
1047
|
+
}
|
|
1048
|
+
const [allocation] = await tx
|
|
1049
|
+
.insert(bookingAllocations)
|
|
1050
|
+
.values({
|
|
1051
|
+
bookingId: booking.id,
|
|
1052
|
+
bookingItemId: bookingItem.id,
|
|
1053
|
+
productId: item.productId ?? slot.product_id,
|
|
1054
|
+
optionId: item.optionId ?? slot.option_id,
|
|
1055
|
+
optionUnitId: item.optionUnitId ?? null,
|
|
1056
|
+
pricingCategoryId: item.pricingCategoryId ?? null,
|
|
1057
|
+
availabilitySlotId: item.availabilitySlotId,
|
|
1058
|
+
quantity: item.quantity,
|
|
1059
|
+
allocationType: item.allocationType,
|
|
1060
|
+
status: "held",
|
|
1061
|
+
holdExpiresAt,
|
|
1062
|
+
metadata: item.metadata ?? null,
|
|
1063
|
+
})
|
|
1064
|
+
.returning();
|
|
1065
|
+
if (!allocation) {
|
|
1066
|
+
throw new BookingServiceError("allocation_create_failed");
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
await tx.insert(bookingActivityLog).values({
|
|
1070
|
+
bookingId: booking.id,
|
|
1071
|
+
actorId: userId ?? "system",
|
|
1072
|
+
activityType: "booking_reserved",
|
|
1073
|
+
description: `Booking ${booking.bookingNumber} reserved and placed on hold`,
|
|
1074
|
+
metadata: { holdExpiresAt: holdExpiresAt.toISOString(), itemCount: data.items.length },
|
|
1075
|
+
});
|
|
1076
|
+
return { status: "ok", booking };
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
if (error instanceof BookingServiceError) {
|
|
1081
|
+
return { status: error.code };
|
|
1082
|
+
}
|
|
1083
|
+
throw error;
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
async createBooking(db, data, userId) {
|
|
1087
|
+
return db.transaction(async (tx) => {
|
|
1088
|
+
const [row] = await tx
|
|
1089
|
+
.insert(bookings)
|
|
1090
|
+
.values({
|
|
1091
|
+
...data,
|
|
1092
|
+
holdExpiresAt: toTimestamp(data.holdExpiresAt),
|
|
1093
|
+
confirmedAt: toTimestamp(data.confirmedAt),
|
|
1094
|
+
expiredAt: toTimestamp(data.expiredAt),
|
|
1095
|
+
cancelledAt: toTimestamp(data.cancelledAt),
|
|
1096
|
+
completedAt: toTimestamp(data.completedAt),
|
|
1097
|
+
redeemedAt: toTimestamp(data.redeemedAt),
|
|
1098
|
+
})
|
|
1099
|
+
.returning();
|
|
1100
|
+
if (!row) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
await tx.insert(bookingActivityLog).values({
|
|
1104
|
+
bookingId: row.id,
|
|
1105
|
+
actorId: userId ?? "system",
|
|
1106
|
+
activityType: "booking_created",
|
|
1107
|
+
description: `Booking ${data.bookingNumber} created`,
|
|
1108
|
+
});
|
|
1109
|
+
return row;
|
|
1110
|
+
});
|
|
1111
|
+
},
|
|
1112
|
+
async updateBooking(db, id, data) {
|
|
1113
|
+
const [row] = await db
|
|
1114
|
+
.update(bookings)
|
|
1115
|
+
.set({
|
|
1116
|
+
...data,
|
|
1117
|
+
holdExpiresAt: data.holdExpiresAt === undefined ? undefined : toTimestamp(data.holdExpiresAt),
|
|
1118
|
+
confirmedAt: data.confirmedAt === undefined ? undefined : toTimestamp(data.confirmedAt),
|
|
1119
|
+
expiredAt: data.expiredAt === undefined ? undefined : toTimestamp(data.expiredAt),
|
|
1120
|
+
cancelledAt: data.cancelledAt === undefined ? undefined : toTimestamp(data.cancelledAt),
|
|
1121
|
+
completedAt: data.completedAt === undefined ? undefined : toTimestamp(data.completedAt),
|
|
1122
|
+
redeemedAt: data.redeemedAt === undefined ? undefined : toTimestamp(data.redeemedAt),
|
|
1123
|
+
updatedAt: new Date(),
|
|
1124
|
+
})
|
|
1125
|
+
.where(eq(bookings.id, id))
|
|
1126
|
+
.returning();
|
|
1127
|
+
return row ?? null;
|
|
1128
|
+
},
|
|
1129
|
+
async deleteBooking(db, id) {
|
|
1130
|
+
const [row] = await db
|
|
1131
|
+
.delete(bookings)
|
|
1132
|
+
.where(eq(bookings.id, id))
|
|
1133
|
+
.returning({ id: bookings.id });
|
|
1134
|
+
return row ?? null;
|
|
1135
|
+
},
|
|
1136
|
+
async updateBookingStatus(db, id, data, userId) {
|
|
1137
|
+
const [current] = await db
|
|
1138
|
+
.select({ id: bookings.id, status: bookings.status })
|
|
1139
|
+
.from(bookings)
|
|
1140
|
+
.where(eq(bookings.id, id))
|
|
1141
|
+
.limit(1);
|
|
1142
|
+
if (!current) {
|
|
1143
|
+
return { status: "not_found" };
|
|
1144
|
+
}
|
|
1145
|
+
if (current.status === "on_hold" && data.status === "confirmed") {
|
|
1146
|
+
return bookingsService.confirmBooking(db, id, { note: data.note }, userId);
|
|
1147
|
+
}
|
|
1148
|
+
if (current.status === "on_hold" && data.status === "expired") {
|
|
1149
|
+
return bookingsService.expireBooking(db, id, { note: data.note }, userId);
|
|
1150
|
+
}
|
|
1151
|
+
if (data.status === "cancelled") {
|
|
1152
|
+
return bookingsService.cancelBooking(db, id, { note: data.note }, userId);
|
|
1153
|
+
}
|
|
1154
|
+
if (data.status === "on_hold") {
|
|
1155
|
+
return { status: "invalid_transition" };
|
|
1156
|
+
}
|
|
1157
|
+
if (!isValidBookingTransition(current.status, data.status)) {
|
|
1158
|
+
return { status: "invalid_transition" };
|
|
1159
|
+
}
|
|
1160
|
+
const [row] = await db
|
|
1161
|
+
.update(bookings)
|
|
1162
|
+
.set({
|
|
1163
|
+
status: data.status,
|
|
1164
|
+
...toBookingStatusTimestamps(data.status),
|
|
1165
|
+
updatedAt: new Date(),
|
|
1166
|
+
})
|
|
1167
|
+
.where(eq(bookings.id, id))
|
|
1168
|
+
.returning();
|
|
1169
|
+
await db.insert(bookingActivityLog).values({
|
|
1170
|
+
bookingId: id,
|
|
1171
|
+
actorId: userId ?? "system",
|
|
1172
|
+
activityType: "status_change",
|
|
1173
|
+
description: `Status changed from ${current.status} to ${data.status}`,
|
|
1174
|
+
metadata: { oldStatus: current.status, newStatus: data.status },
|
|
1175
|
+
});
|
|
1176
|
+
if (data.note) {
|
|
1177
|
+
await db.insert(bookingNotes).values({
|
|
1178
|
+
bookingId: id,
|
|
1179
|
+
authorId: userId ?? "system",
|
|
1180
|
+
content: data.note,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
if (data.status === "confirmed") {
|
|
1184
|
+
await autoIssueFulfillmentsForBooking(db, id, userId);
|
|
1185
|
+
}
|
|
1186
|
+
return { status: "ok", booking: row ?? null };
|
|
1187
|
+
},
|
|
1188
|
+
async confirmBooking(db, id, data, userId) {
|
|
1189
|
+
try {
|
|
1190
|
+
return await db.transaction(async (tx) => {
|
|
1191
|
+
const rows = await tx.execute(sql `SELECT id, booking_number, status, hold_expires_at
|
|
1192
|
+
FROM ${bookings}
|
|
1193
|
+
WHERE ${bookings.id} = ${id}
|
|
1194
|
+
FOR UPDATE`);
|
|
1195
|
+
const booking = rows[0];
|
|
1196
|
+
if (!booking) {
|
|
1197
|
+
throw new BookingServiceError("not_found");
|
|
1198
|
+
}
|
|
1199
|
+
if (booking.status !== "on_hold") {
|
|
1200
|
+
throw new BookingServiceError("invalid_transition");
|
|
1201
|
+
}
|
|
1202
|
+
if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
|
|
1203
|
+
throw new BookingServiceError("hold_expired");
|
|
1204
|
+
}
|
|
1205
|
+
await tx
|
|
1206
|
+
.update(bookingAllocations)
|
|
1207
|
+
.set({
|
|
1208
|
+
status: "confirmed",
|
|
1209
|
+
confirmedAt: new Date(),
|
|
1210
|
+
updatedAt: new Date(),
|
|
1211
|
+
})
|
|
1212
|
+
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
|
|
1213
|
+
await tx
|
|
1214
|
+
.update(bookingItems)
|
|
1215
|
+
.set({ status: "confirmed", updatedAt: new Date() })
|
|
1216
|
+
.where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
|
|
1217
|
+
const [row] = await tx
|
|
1218
|
+
.update(bookings)
|
|
1219
|
+
.set({
|
|
1220
|
+
status: "confirmed",
|
|
1221
|
+
holdExpiresAt: null,
|
|
1222
|
+
confirmedAt: new Date(),
|
|
1223
|
+
updatedAt: new Date(),
|
|
1224
|
+
})
|
|
1225
|
+
.where(eq(bookings.id, id))
|
|
1226
|
+
.returning();
|
|
1227
|
+
await syncTransactionOnBookingConfirmed(tx, id);
|
|
1228
|
+
await autoIssueFulfillmentsForBooking(tx, id, userId);
|
|
1229
|
+
await tx.insert(bookingActivityLog).values({
|
|
1230
|
+
bookingId: id,
|
|
1231
|
+
actorId: userId ?? "system",
|
|
1232
|
+
activityType: "booking_confirmed",
|
|
1233
|
+
description: `Booking ${booking.booking_number} confirmed`,
|
|
1234
|
+
});
|
|
1235
|
+
if (data.note) {
|
|
1236
|
+
await tx.insert(bookingNotes).values({
|
|
1237
|
+
bookingId: id,
|
|
1238
|
+
authorId: userId ?? "system",
|
|
1239
|
+
content: data.note,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
return { status: "ok", booking: row ?? null };
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
if (error instanceof BookingServiceError) {
|
|
1247
|
+
return { status: error.code };
|
|
1248
|
+
}
|
|
1249
|
+
throw error;
|
|
1250
|
+
}
|
|
1251
|
+
},
|
|
1252
|
+
async extendBookingHold(db, id, data, userId) {
|
|
1253
|
+
try {
|
|
1254
|
+
return await db.transaction(async (tx) => {
|
|
1255
|
+
const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
|
|
1256
|
+
FROM ${bookings}
|
|
1257
|
+
WHERE ${bookings.id} = ${id}
|
|
1258
|
+
FOR UPDATE`);
|
|
1259
|
+
const booking = rows[0];
|
|
1260
|
+
if (!booking) {
|
|
1261
|
+
throw new BookingServiceError("not_found");
|
|
1262
|
+
}
|
|
1263
|
+
if (booking.status !== "on_hold") {
|
|
1264
|
+
throw new BookingServiceError("invalid_transition");
|
|
1265
|
+
}
|
|
1266
|
+
if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
|
|
1267
|
+
throw new BookingServiceError("hold_expired");
|
|
1268
|
+
}
|
|
1269
|
+
const holdExpiresAt = computeHoldExpiresAt(data);
|
|
1270
|
+
await tx
|
|
1271
|
+
.update(bookingAllocations)
|
|
1272
|
+
.set({
|
|
1273
|
+
holdExpiresAt,
|
|
1274
|
+
updatedAt: new Date(),
|
|
1275
|
+
})
|
|
1276
|
+
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
|
|
1277
|
+
const [row] = await tx
|
|
1278
|
+
.update(bookings)
|
|
1279
|
+
.set({
|
|
1280
|
+
holdExpiresAt,
|
|
1281
|
+
updatedAt: new Date(),
|
|
1282
|
+
})
|
|
1283
|
+
.where(eq(bookings.id, id))
|
|
1284
|
+
.returning();
|
|
1285
|
+
await tx.insert(bookingActivityLog).values({
|
|
1286
|
+
bookingId: id,
|
|
1287
|
+
actorId: userId ?? "system",
|
|
1288
|
+
activityType: "hold_extended",
|
|
1289
|
+
description: "Booking hold extended",
|
|
1290
|
+
metadata: { holdExpiresAt: holdExpiresAt.toISOString() },
|
|
1291
|
+
});
|
|
1292
|
+
return { status: "ok", booking: row ?? null };
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
catch (error) {
|
|
1296
|
+
if (error instanceof BookingServiceError) {
|
|
1297
|
+
return { status: error.code };
|
|
1298
|
+
}
|
|
1299
|
+
throw error;
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
async expireBooking(db, id, data, userId) {
|
|
1303
|
+
try {
|
|
1304
|
+
return await db.transaction(async (tx) => {
|
|
1305
|
+
const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
|
|
1306
|
+
FROM ${bookings}
|
|
1307
|
+
WHERE ${bookings.id} = ${id}
|
|
1308
|
+
FOR UPDATE`);
|
|
1309
|
+
const booking = rows[0];
|
|
1310
|
+
if (!booking) {
|
|
1311
|
+
throw new BookingServiceError("not_found");
|
|
1312
|
+
}
|
|
1313
|
+
if (booking.status !== "on_hold") {
|
|
1314
|
+
throw new BookingServiceError("invalid_transition");
|
|
1315
|
+
}
|
|
1316
|
+
const allocations = await tx
|
|
1317
|
+
.select()
|
|
1318
|
+
.from(bookingAllocations)
|
|
1319
|
+
.where(eq(bookingAllocations.bookingId, id));
|
|
1320
|
+
for (const allocation of allocations) {
|
|
1321
|
+
await releaseAllocationCapacity(tx, allocation);
|
|
1322
|
+
}
|
|
1323
|
+
await tx
|
|
1324
|
+
.update(bookingAllocations)
|
|
1325
|
+
.set({
|
|
1326
|
+
status: "expired",
|
|
1327
|
+
releasedAt: new Date(),
|
|
1328
|
+
updatedAt: new Date(),
|
|
1329
|
+
})
|
|
1330
|
+
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
|
|
1331
|
+
await tx
|
|
1332
|
+
.update(bookingItems)
|
|
1333
|
+
.set({ status: "expired", updatedAt: new Date() })
|
|
1334
|
+
.where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
|
|
1335
|
+
const [row] = await tx
|
|
1336
|
+
.update(bookings)
|
|
1337
|
+
.set({
|
|
1338
|
+
status: "expired",
|
|
1339
|
+
holdExpiresAt: null,
|
|
1340
|
+
expiredAt: new Date(),
|
|
1341
|
+
updatedAt: new Date(),
|
|
1342
|
+
})
|
|
1343
|
+
.where(eq(bookings.id, id))
|
|
1344
|
+
.returning();
|
|
1345
|
+
await syncTransactionOnBookingExpired(tx, id);
|
|
1346
|
+
await tx.insert(bookingActivityLog).values({
|
|
1347
|
+
bookingId: id,
|
|
1348
|
+
actorId: userId ?? "system",
|
|
1349
|
+
activityType: "hold_expired",
|
|
1350
|
+
description: "Booking hold expired",
|
|
1351
|
+
});
|
|
1352
|
+
if (data.note) {
|
|
1353
|
+
await tx.insert(bookingNotes).values({
|
|
1354
|
+
bookingId: id,
|
|
1355
|
+
authorId: userId ?? "system",
|
|
1356
|
+
content: data.note,
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
return { status: "ok", booking: row ?? null };
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
catch (error) {
|
|
1363
|
+
if (error instanceof BookingServiceError) {
|
|
1364
|
+
return { status: error.code };
|
|
1365
|
+
}
|
|
1366
|
+
throw error;
|
|
1367
|
+
}
|
|
1368
|
+
},
|
|
1369
|
+
async expireStaleBookings(db, data, userId) {
|
|
1370
|
+
const cutoff = data.before ? new Date(data.before) : new Date();
|
|
1371
|
+
const staleBookings = await db
|
|
1372
|
+
.select({ id: bookings.id })
|
|
1373
|
+
.from(bookings)
|
|
1374
|
+
.where(and(eq(bookings.status, "on_hold"), sql `${bookings.holdExpiresAt} IS NOT NULL`, lte(bookings.holdExpiresAt, cutoff)))
|
|
1375
|
+
.orderBy(asc(bookings.holdExpiresAt), asc(bookings.createdAt));
|
|
1376
|
+
const expiredIds = [];
|
|
1377
|
+
for (const booking of staleBookings) {
|
|
1378
|
+
const result = await this.expireBooking(db, booking.id, { note: data.note ?? "Hold expired by sweep" }, userId);
|
|
1379
|
+
if ("booking" in result && result.booking) {
|
|
1380
|
+
expiredIds.push(result.booking.id);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return {
|
|
1384
|
+
expiredIds,
|
|
1385
|
+
count: expiredIds.length,
|
|
1386
|
+
cutoff,
|
|
1387
|
+
};
|
|
1388
|
+
},
|
|
1389
|
+
async cancelBooking(db, id, data, userId) {
|
|
1390
|
+
try {
|
|
1391
|
+
return await db.transaction(async (tx) => {
|
|
1392
|
+
const rows = await tx.execute(sql `SELECT id, status
|
|
1393
|
+
FROM ${bookings}
|
|
1394
|
+
WHERE ${bookings.id} = ${id}
|
|
1395
|
+
FOR UPDATE`);
|
|
1396
|
+
const booking = rows[0];
|
|
1397
|
+
if (!booking) {
|
|
1398
|
+
throw new BookingServiceError("not_found");
|
|
1399
|
+
}
|
|
1400
|
+
if (!["draft", "on_hold", "confirmed", "in_progress"].includes(booking.status)) {
|
|
1401
|
+
throw new BookingServiceError("invalid_transition");
|
|
1402
|
+
}
|
|
1403
|
+
const allocations = await tx
|
|
1404
|
+
.select()
|
|
1405
|
+
.from(bookingAllocations)
|
|
1406
|
+
.where(eq(bookingAllocations.bookingId, id));
|
|
1407
|
+
for (const allocation of allocations) {
|
|
1408
|
+
await releaseAllocationCapacity(tx, allocation);
|
|
1409
|
+
}
|
|
1410
|
+
await tx
|
|
1411
|
+
.update(bookingAllocations)
|
|
1412
|
+
.set({
|
|
1413
|
+
status: "cancelled",
|
|
1414
|
+
releasedAt: new Date(),
|
|
1415
|
+
updatedAt: new Date(),
|
|
1416
|
+
})
|
|
1417
|
+
.where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
|
|
1418
|
+
await tx
|
|
1419
|
+
.update(bookingItems)
|
|
1420
|
+
.set({
|
|
1421
|
+
status: "cancelled",
|
|
1422
|
+
updatedAt: new Date(),
|
|
1423
|
+
})
|
|
1424
|
+
.where(and(eq(bookingItems.bookingId, id), or(eq(bookingItems.status, "draft"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "confirmed"))));
|
|
1425
|
+
const [row] = await tx
|
|
1426
|
+
.update(bookings)
|
|
1427
|
+
.set({
|
|
1428
|
+
status: "cancelled",
|
|
1429
|
+
holdExpiresAt: null,
|
|
1430
|
+
cancelledAt: new Date(),
|
|
1431
|
+
updatedAt: new Date(),
|
|
1432
|
+
})
|
|
1433
|
+
.where(eq(bookings.id, id))
|
|
1434
|
+
.returning();
|
|
1435
|
+
await syncTransactionOnBookingCancelled(tx, id);
|
|
1436
|
+
await tx.insert(bookingActivityLog).values({
|
|
1437
|
+
bookingId: id,
|
|
1438
|
+
actorId: userId ?? "system",
|
|
1439
|
+
activityType: "status_change",
|
|
1440
|
+
description: `Booking cancelled from ${booking.status}`,
|
|
1441
|
+
metadata: { oldStatus: booking.status, newStatus: "cancelled" },
|
|
1442
|
+
});
|
|
1443
|
+
if (data.note) {
|
|
1444
|
+
await tx.insert(bookingNotes).values({
|
|
1445
|
+
bookingId: id,
|
|
1446
|
+
authorId: userId ?? "system",
|
|
1447
|
+
content: data.note,
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
return { status: "ok", booking: row ?? null };
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
if (error instanceof BookingServiceError) {
|
|
1455
|
+
return { status: error.code };
|
|
1456
|
+
}
|
|
1457
|
+
throw error;
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
listParticipants(db, bookingId) {
|
|
1461
|
+
return db
|
|
1462
|
+
.select()
|
|
1463
|
+
.from(bookingParticipants)
|
|
1464
|
+
.where(eq(bookingParticipants.bookingId, bookingId))
|
|
1465
|
+
.orderBy(desc(bookingParticipants.isPrimary), asc(bookingParticipants.createdAt));
|
|
1466
|
+
},
|
|
1467
|
+
async getParticipantById(db, bookingId, participantId) {
|
|
1468
|
+
const [row] = await db
|
|
1469
|
+
.select()
|
|
1470
|
+
.from(bookingParticipants)
|
|
1471
|
+
.where(and(eq(bookingParticipants.id, participantId), eq(bookingParticipants.bookingId, bookingId)))
|
|
1472
|
+
.limit(1);
|
|
1473
|
+
return row ?? null;
|
|
1474
|
+
},
|
|
1475
|
+
async createParticipant(db, bookingId, data, userId) {
|
|
1476
|
+
const [booking] = await db
|
|
1477
|
+
.select({ id: bookings.id })
|
|
1478
|
+
.from(bookings)
|
|
1479
|
+
.where(eq(bookings.id, bookingId))
|
|
1480
|
+
.limit(1);
|
|
1481
|
+
if (!booking) {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
const [row] = await db
|
|
1485
|
+
.insert(bookingParticipants)
|
|
1486
|
+
.values({
|
|
1487
|
+
bookingId,
|
|
1488
|
+
personId: data.personId ?? null,
|
|
1489
|
+
participantType: data.participantType,
|
|
1490
|
+
travelerCategory: data.travelerCategory ?? null,
|
|
1491
|
+
firstName: data.firstName,
|
|
1492
|
+
lastName: data.lastName,
|
|
1493
|
+
email: data.email ?? null,
|
|
1494
|
+
phone: data.phone ?? null,
|
|
1495
|
+
preferredLanguage: data.preferredLanguage ?? null,
|
|
1496
|
+
accessibilityNeeds: data.accessibilityNeeds ?? null,
|
|
1497
|
+
specialRequests: data.specialRequests ?? null,
|
|
1498
|
+
isPrimary: data.isPrimary ?? false,
|
|
1499
|
+
notes: data.notes ?? null,
|
|
1500
|
+
})
|
|
1501
|
+
.returning();
|
|
1502
|
+
if (!row) {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
await ensureParticipantFlags(db, bookingId, row.id, data);
|
|
1506
|
+
await db.insert(bookingActivityLog).values({
|
|
1507
|
+
bookingId,
|
|
1508
|
+
actorId: userId ?? "system",
|
|
1509
|
+
activityType: "passenger_update",
|
|
1510
|
+
description: `Participant ${data.firstName} ${data.lastName} added`,
|
|
1511
|
+
metadata: { participantId: row.id, participantType: data.participantType },
|
|
1512
|
+
});
|
|
1513
|
+
return row;
|
|
1514
|
+
},
|
|
1515
|
+
async updateParticipant(db, participantId, data) {
|
|
1516
|
+
const [row] = await db
|
|
1517
|
+
.update(bookingParticipants)
|
|
1518
|
+
.set({ ...data, updatedAt: new Date() })
|
|
1519
|
+
.where(eq(bookingParticipants.id, participantId))
|
|
1520
|
+
.returning();
|
|
1521
|
+
if (!row) {
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
await ensureParticipantFlags(db, row.bookingId, row.id, data);
|
|
1525
|
+
return row;
|
|
1526
|
+
},
|
|
1527
|
+
async deleteParticipant(db, participantId) {
|
|
1528
|
+
const [row] = await db
|
|
1529
|
+
.delete(bookingParticipants)
|
|
1530
|
+
.where(eq(bookingParticipants.id, participantId))
|
|
1531
|
+
.returning({ id: bookingParticipants.id });
|
|
1532
|
+
return row ?? null;
|
|
1533
|
+
},
|
|
1534
|
+
listPassengers(db, bookingId) {
|
|
1535
|
+
return db
|
|
1536
|
+
.select()
|
|
1537
|
+
.from(bookingParticipants)
|
|
1538
|
+
.where(and(eq(bookingParticipants.bookingId, bookingId), or(...travelerParticipantTypes.map((type) => eq(bookingParticipants.participantType, type)))))
|
|
1539
|
+
.orderBy(asc(bookingParticipants.createdAt))
|
|
1540
|
+
.then((rows) => rows.map(toPassengerResponse));
|
|
1541
|
+
},
|
|
1542
|
+
async createPassenger(db, bookingId, data, userId) {
|
|
1543
|
+
const row = await this.createParticipant(db, bookingId, toCreateParticipantFromPassenger(data), userId);
|
|
1544
|
+
return row ? toPassengerResponse(row) : null;
|
|
1545
|
+
},
|
|
1546
|
+
async updatePassenger(db, passengerId, data) {
|
|
1547
|
+
const row = await this.updateParticipant(db, passengerId, toUpdateParticipantFromPassenger(data));
|
|
1548
|
+
return row ? toPassengerResponse(row) : null;
|
|
1549
|
+
},
|
|
1550
|
+
async deletePassenger(db, passengerId) {
|
|
1551
|
+
return this.deleteParticipant(db, passengerId);
|
|
1552
|
+
},
|
|
1553
|
+
listItems(db, bookingId) {
|
|
1554
|
+
return db
|
|
1555
|
+
.select()
|
|
1556
|
+
.from(bookingItems)
|
|
1557
|
+
.where(eq(bookingItems.bookingId, bookingId))
|
|
1558
|
+
.orderBy(asc(bookingItems.createdAt));
|
|
1559
|
+
},
|
|
1560
|
+
async createItem(db, bookingId, data, userId) {
|
|
1561
|
+
const [booking] = await db
|
|
1562
|
+
.select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
|
|
1563
|
+
.from(bookings)
|
|
1564
|
+
.where(eq(bookings.id, bookingId))
|
|
1565
|
+
.limit(1);
|
|
1566
|
+
if (!booking) {
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
const [row] = await db
|
|
1570
|
+
.insert(bookingItems)
|
|
1571
|
+
.values({
|
|
1572
|
+
bookingId,
|
|
1573
|
+
title: data.title,
|
|
1574
|
+
description: data.description ?? null,
|
|
1575
|
+
itemType: data.itemType,
|
|
1576
|
+
status: data.status,
|
|
1577
|
+
serviceDate: data.serviceDate ?? null,
|
|
1578
|
+
startsAt: toTimestamp(data.startsAt),
|
|
1579
|
+
endsAt: toTimestamp(data.endsAt),
|
|
1580
|
+
quantity: data.quantity,
|
|
1581
|
+
sellCurrency: data.sellCurrency ?? booking.sellCurrency,
|
|
1582
|
+
unitSellAmountCents: data.unitSellAmountCents ?? null,
|
|
1583
|
+
totalSellAmountCents: data.totalSellAmountCents ?? null,
|
|
1584
|
+
costCurrency: data.costCurrency ?? null,
|
|
1585
|
+
unitCostAmountCents: data.unitCostAmountCents ?? null,
|
|
1586
|
+
totalCostAmountCents: data.totalCostAmountCents ?? null,
|
|
1587
|
+
notes: data.notes ?? null,
|
|
1588
|
+
productId: data.productId ?? null,
|
|
1589
|
+
optionId: data.optionId ?? null,
|
|
1590
|
+
optionUnitId: data.optionUnitId ?? null,
|
|
1591
|
+
pricingCategoryId: data.pricingCategoryId ?? null,
|
|
1592
|
+
sourceSnapshotId: data.sourceSnapshotId ?? null,
|
|
1593
|
+
sourceOfferId: data.sourceOfferId ?? null,
|
|
1594
|
+
metadata: data.metadata ?? null,
|
|
1595
|
+
})
|
|
1596
|
+
.returning();
|
|
1597
|
+
if (!row) {
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
await db.insert(bookingActivityLog).values({
|
|
1601
|
+
bookingId,
|
|
1602
|
+
actorId: userId ?? "system",
|
|
1603
|
+
activityType: "item_update",
|
|
1604
|
+
description: `Booking item "${data.title}" added`,
|
|
1605
|
+
metadata: { bookingItemId: row.id, itemType: data.itemType },
|
|
1606
|
+
});
|
|
1607
|
+
return row;
|
|
1608
|
+
},
|
|
1609
|
+
async updateItem(db, itemId, data) {
|
|
1610
|
+
const [row] = await db
|
|
1611
|
+
.update(bookingItems)
|
|
1612
|
+
.set({
|
|
1613
|
+
...data,
|
|
1614
|
+
startsAt: data.startsAt === undefined ? undefined : toTimestamp(data.startsAt),
|
|
1615
|
+
endsAt: data.endsAt === undefined ? undefined : toTimestamp(data.endsAt),
|
|
1616
|
+
updatedAt: new Date(),
|
|
1617
|
+
})
|
|
1618
|
+
.where(eq(bookingItems.id, itemId))
|
|
1619
|
+
.returning();
|
|
1620
|
+
return row ?? null;
|
|
1621
|
+
},
|
|
1622
|
+
async deleteItem(db, itemId) {
|
|
1623
|
+
const [row] = await db
|
|
1624
|
+
.delete(bookingItems)
|
|
1625
|
+
.where(eq(bookingItems.id, itemId))
|
|
1626
|
+
.returning({ id: bookingItems.id });
|
|
1627
|
+
return row ?? null;
|
|
1628
|
+
},
|
|
1629
|
+
listItemParticipants(db, itemId) {
|
|
1630
|
+
return db
|
|
1631
|
+
.select()
|
|
1632
|
+
.from(bookingItemParticipants)
|
|
1633
|
+
.where(eq(bookingItemParticipants.bookingItemId, itemId))
|
|
1634
|
+
.orderBy(desc(bookingItemParticipants.isPrimary), asc(bookingItemParticipants.createdAt));
|
|
1635
|
+
},
|
|
1636
|
+
async addItemParticipant(db, itemId, data) {
|
|
1637
|
+
const [item] = await db
|
|
1638
|
+
.select({ id: bookingItems.id })
|
|
1639
|
+
.from(bookingItems)
|
|
1640
|
+
.where(eq(bookingItems.id, itemId))
|
|
1641
|
+
.limit(1);
|
|
1642
|
+
if (!item) {
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
const [participant] = await db
|
|
1646
|
+
.select({ id: bookingParticipants.id })
|
|
1647
|
+
.from(bookingParticipants)
|
|
1648
|
+
.where(eq(bookingParticipants.id, data.participantId))
|
|
1649
|
+
.limit(1);
|
|
1650
|
+
if (!participant) {
|
|
1651
|
+
return null;
|
|
1652
|
+
}
|
|
1653
|
+
if (data.isPrimary) {
|
|
1654
|
+
await db
|
|
1655
|
+
.update(bookingItemParticipants)
|
|
1656
|
+
.set({ isPrimary: false })
|
|
1657
|
+
.where(eq(bookingItemParticipants.bookingItemId, itemId));
|
|
1658
|
+
}
|
|
1659
|
+
const [row] = await db
|
|
1660
|
+
.insert(bookingItemParticipants)
|
|
1661
|
+
.values({
|
|
1662
|
+
bookingItemId: itemId,
|
|
1663
|
+
participantId: data.participantId,
|
|
1664
|
+
role: data.role,
|
|
1665
|
+
isPrimary: data.isPrimary ?? false,
|
|
1666
|
+
})
|
|
1667
|
+
.returning();
|
|
1668
|
+
return row;
|
|
1669
|
+
},
|
|
1670
|
+
async removeItemParticipant(db, linkId) {
|
|
1671
|
+
const [row] = await db
|
|
1672
|
+
.delete(bookingItemParticipants)
|
|
1673
|
+
.where(eq(bookingItemParticipants.id, linkId))
|
|
1674
|
+
.returning({ id: bookingItemParticipants.id });
|
|
1675
|
+
return row ?? null;
|
|
1676
|
+
},
|
|
1677
|
+
listSupplierStatuses(db, bookingId) {
|
|
1678
|
+
return db
|
|
1679
|
+
.select()
|
|
1680
|
+
.from(bookingSupplierStatuses)
|
|
1681
|
+
.where(eq(bookingSupplierStatuses.bookingId, bookingId))
|
|
1682
|
+
.orderBy(asc(bookingSupplierStatuses.createdAt));
|
|
1683
|
+
},
|
|
1684
|
+
async createSupplierStatus(db, bookingId, data, userId) {
|
|
1685
|
+
const [booking] = await db
|
|
1686
|
+
.select({ id: bookings.id })
|
|
1687
|
+
.from(bookings)
|
|
1688
|
+
.where(eq(bookings.id, bookingId))
|
|
1689
|
+
.limit(1);
|
|
1690
|
+
if (!booking) {
|
|
1691
|
+
return null;
|
|
1692
|
+
}
|
|
1693
|
+
const [row] = await db
|
|
1694
|
+
.insert(bookingSupplierStatuses)
|
|
1695
|
+
.values({
|
|
1696
|
+
bookingId,
|
|
1697
|
+
supplierServiceId: data.supplierServiceId ?? null,
|
|
1698
|
+
serviceName: data.serviceName,
|
|
1699
|
+
status: data.status ?? "pending",
|
|
1700
|
+
supplierReference: data.supplierReference ?? null,
|
|
1701
|
+
costCurrency: data.costCurrency,
|
|
1702
|
+
costAmountCents: data.costAmountCents,
|
|
1703
|
+
notes: data.notes ?? null,
|
|
1704
|
+
confirmedAt: data.status === "confirmed" ? new Date() : null,
|
|
1705
|
+
})
|
|
1706
|
+
.returning();
|
|
1707
|
+
await db.insert(bookingActivityLog).values({
|
|
1708
|
+
bookingId,
|
|
1709
|
+
actorId: userId ?? "system",
|
|
1710
|
+
activityType: "supplier_update",
|
|
1711
|
+
description: `Supplier status for "${data.serviceName}" added`,
|
|
1712
|
+
});
|
|
1713
|
+
return row ?? null;
|
|
1714
|
+
},
|
|
1715
|
+
async updateSupplierStatus(db, bookingId, statusId, data, userId) {
|
|
1716
|
+
const updateData = {
|
|
1717
|
+
...data,
|
|
1718
|
+
supplierServiceId: data.supplierServiceId ?? undefined,
|
|
1719
|
+
supplierReference: data.supplierReference ?? undefined,
|
|
1720
|
+
confirmedAt: data.confirmedAt !== undefined
|
|
1721
|
+
? toTimestamp(data.confirmedAt)
|
|
1722
|
+
: data.status === "confirmed"
|
|
1723
|
+
? new Date()
|
|
1724
|
+
: undefined,
|
|
1725
|
+
updatedAt: new Date(),
|
|
1726
|
+
};
|
|
1727
|
+
const [row] = await db
|
|
1728
|
+
.update(bookingSupplierStatuses)
|
|
1729
|
+
.set(updateData)
|
|
1730
|
+
.where(eq(bookingSupplierStatuses.id, statusId))
|
|
1731
|
+
.returning();
|
|
1732
|
+
if (!row) {
|
|
1733
|
+
return null;
|
|
1734
|
+
}
|
|
1735
|
+
if (data.status) {
|
|
1736
|
+
await db.insert(bookingActivityLog).values({
|
|
1737
|
+
bookingId,
|
|
1738
|
+
actorId: userId ?? "system",
|
|
1739
|
+
activityType: "supplier_update",
|
|
1740
|
+
description: `Supplier "${row.serviceName}" status updated to ${data.status}`,
|
|
1741
|
+
metadata: { supplierStatusId: statusId, newStatus: data.status },
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
return row;
|
|
1745
|
+
},
|
|
1746
|
+
listFulfillments(db, bookingId) {
|
|
1747
|
+
return db
|
|
1748
|
+
.select()
|
|
1749
|
+
.from(bookingFulfillments)
|
|
1750
|
+
.where(eq(bookingFulfillments.bookingId, bookingId))
|
|
1751
|
+
.orderBy(desc(bookingFulfillments.createdAt));
|
|
1752
|
+
},
|
|
1753
|
+
async issueFulfillment(db, bookingId, data, userId) {
|
|
1754
|
+
const [booking] = await db
|
|
1755
|
+
.select({ id: bookings.id })
|
|
1756
|
+
.from(bookings)
|
|
1757
|
+
.where(eq(bookings.id, bookingId))
|
|
1758
|
+
.limit(1);
|
|
1759
|
+
if (!booking) {
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
const scoped = await ensureBookingScopedLinks(db, bookingId, data);
|
|
1763
|
+
if (!scoped.ok) {
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
const status = data.status ?? "issued";
|
|
1767
|
+
const issuedAt = data.issuedAt !== undefined
|
|
1768
|
+
? toTimestamp(data.issuedAt)
|
|
1769
|
+
: status === "issued" || status === "reissued"
|
|
1770
|
+
? new Date()
|
|
1771
|
+
: null;
|
|
1772
|
+
const revokedAt = data.revokedAt !== undefined
|
|
1773
|
+
? toTimestamp(data.revokedAt)
|
|
1774
|
+
: status === "revoked"
|
|
1775
|
+
? new Date()
|
|
1776
|
+
: null;
|
|
1777
|
+
const [row] = await db
|
|
1778
|
+
.insert(bookingFulfillments)
|
|
1779
|
+
.values({
|
|
1780
|
+
bookingId,
|
|
1781
|
+
bookingItemId: data.bookingItemId ?? null,
|
|
1782
|
+
participantId: data.participantId ?? null,
|
|
1783
|
+
fulfillmentType: data.fulfillmentType,
|
|
1784
|
+
deliveryChannel: data.deliveryChannel,
|
|
1785
|
+
status,
|
|
1786
|
+
artifactUrl: data.artifactUrl ?? null,
|
|
1787
|
+
payload: data.payload ?? null,
|
|
1788
|
+
issuedAt,
|
|
1789
|
+
revokedAt,
|
|
1790
|
+
})
|
|
1791
|
+
.returning();
|
|
1792
|
+
await db.insert(bookingActivityLog).values({
|
|
1793
|
+
bookingId,
|
|
1794
|
+
actorId: userId ?? "system",
|
|
1795
|
+
activityType: "fulfillment_issued",
|
|
1796
|
+
description: `Booking fulfillment issued as ${data.fulfillmentType}`,
|
|
1797
|
+
metadata: {
|
|
1798
|
+
fulfillmentId: row?.id ?? null,
|
|
1799
|
+
bookingItemId: data.bookingItemId ?? null,
|
|
1800
|
+
participantId: data.participantId ?? null,
|
|
1801
|
+
status,
|
|
1802
|
+
},
|
|
1803
|
+
});
|
|
1804
|
+
return row ?? null;
|
|
1805
|
+
},
|
|
1806
|
+
async updateFulfillment(db, bookingId, fulfillmentId, data, userId) {
|
|
1807
|
+
const [existing] = await db
|
|
1808
|
+
.select({ id: bookingFulfillments.id })
|
|
1809
|
+
.from(bookingFulfillments)
|
|
1810
|
+
.where(and(eq(bookingFulfillments.id, fulfillmentId), eq(bookingFulfillments.bookingId, bookingId)))
|
|
1811
|
+
.limit(1);
|
|
1812
|
+
if (!existing) {
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
const scoped = await ensureBookingScopedLinks(db, bookingId, data);
|
|
1816
|
+
if (!scoped.ok) {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
const nextStatus = data.status;
|
|
1820
|
+
const [row] = await db
|
|
1821
|
+
.update(bookingFulfillments)
|
|
1822
|
+
.set({
|
|
1823
|
+
bookingItemId: data.bookingItemId === undefined ? undefined : (data.bookingItemId ?? null),
|
|
1824
|
+
participantId: data.participantId === undefined ? undefined : (data.participantId ?? null),
|
|
1825
|
+
fulfillmentType: data.fulfillmentType,
|
|
1826
|
+
deliveryChannel: data.deliveryChannel,
|
|
1827
|
+
status: nextStatus,
|
|
1828
|
+
artifactUrl: data.artifactUrl === undefined ? undefined : (data.artifactUrl ?? null),
|
|
1829
|
+
payload: data.payload === undefined ? undefined : (data.payload ?? null),
|
|
1830
|
+
issuedAt: data.issuedAt !== undefined
|
|
1831
|
+
? toTimestamp(data.issuedAt)
|
|
1832
|
+
: nextStatus === "issued" || nextStatus === "reissued"
|
|
1833
|
+
? new Date()
|
|
1834
|
+
: undefined,
|
|
1835
|
+
revokedAt: data.revokedAt !== undefined
|
|
1836
|
+
? toTimestamp(data.revokedAt)
|
|
1837
|
+
: nextStatus === "revoked"
|
|
1838
|
+
? new Date()
|
|
1839
|
+
: undefined,
|
|
1840
|
+
updatedAt: new Date(),
|
|
1841
|
+
})
|
|
1842
|
+
.where(eq(bookingFulfillments.id, fulfillmentId))
|
|
1843
|
+
.returning();
|
|
1844
|
+
if (row) {
|
|
1845
|
+
await db.insert(bookingActivityLog).values({
|
|
1846
|
+
bookingId,
|
|
1847
|
+
actorId: userId ?? "system",
|
|
1848
|
+
activityType: "fulfillment_updated",
|
|
1849
|
+
description: `Booking fulfillment ${fulfillmentId} updated`,
|
|
1850
|
+
metadata: {
|
|
1851
|
+
fulfillmentId,
|
|
1852
|
+
bookingItemId: row.bookingItemId,
|
|
1853
|
+
participantId: row.participantId,
|
|
1854
|
+
status: row.status,
|
|
1855
|
+
},
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
return row ?? null;
|
|
1859
|
+
},
|
|
1860
|
+
listRedemptionEvents(db, bookingId) {
|
|
1861
|
+
return db
|
|
1862
|
+
.select()
|
|
1863
|
+
.from(bookingRedemptionEvents)
|
|
1864
|
+
.where(eq(bookingRedemptionEvents.bookingId, bookingId))
|
|
1865
|
+
.orderBy(desc(bookingRedemptionEvents.redeemedAt), desc(bookingRedemptionEvents.createdAt));
|
|
1866
|
+
},
|
|
1867
|
+
async recordRedemption(db, bookingId, data, userId) {
|
|
1868
|
+
return db.transaction(async (tx) => {
|
|
1869
|
+
const [booking] = await tx
|
|
1870
|
+
.select({
|
|
1871
|
+
id: bookings.id,
|
|
1872
|
+
redeemedAt: bookings.redeemedAt,
|
|
1873
|
+
})
|
|
1874
|
+
.from(bookings)
|
|
1875
|
+
.where(eq(bookings.id, bookingId))
|
|
1876
|
+
.limit(1);
|
|
1877
|
+
if (!booking) {
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
const scoped = await ensureBookingScopedLinks(tx, bookingId, data);
|
|
1881
|
+
if (!scoped.ok) {
|
|
1882
|
+
return null;
|
|
1883
|
+
}
|
|
1884
|
+
const redeemedAt = toTimestamp(data.redeemedAt) ?? new Date();
|
|
1885
|
+
const [event] = await tx
|
|
1886
|
+
.insert(bookingRedemptionEvents)
|
|
1887
|
+
.values({
|
|
1888
|
+
bookingId,
|
|
1889
|
+
bookingItemId: data.bookingItemId ?? null,
|
|
1890
|
+
participantId: data.participantId ?? null,
|
|
1891
|
+
redeemedAt,
|
|
1892
|
+
redeemedBy: data.redeemedBy ?? userId ?? null,
|
|
1893
|
+
location: data.location ?? null,
|
|
1894
|
+
method: data.method,
|
|
1895
|
+
metadata: data.metadata ?? null,
|
|
1896
|
+
})
|
|
1897
|
+
.returning();
|
|
1898
|
+
if (!booking.redeemedAt || booking.redeemedAt < redeemedAt) {
|
|
1899
|
+
await tx
|
|
1900
|
+
.update(bookings)
|
|
1901
|
+
.set({
|
|
1902
|
+
redeemedAt,
|
|
1903
|
+
updatedAt: new Date(),
|
|
1904
|
+
})
|
|
1905
|
+
.where(eq(bookings.id, bookingId));
|
|
1906
|
+
}
|
|
1907
|
+
if (data.bookingItemId) {
|
|
1908
|
+
await tx
|
|
1909
|
+
.update(bookingItems)
|
|
1910
|
+
.set({
|
|
1911
|
+
status: "fulfilled",
|
|
1912
|
+
updatedAt: new Date(),
|
|
1913
|
+
})
|
|
1914
|
+
.where(and(eq(bookingItems.id, data.bookingItemId), eq(bookingItems.bookingId, bookingId), or(eq(bookingItems.status, "confirmed"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "draft"))));
|
|
1915
|
+
await tx
|
|
1916
|
+
.update(bookingAllocations)
|
|
1917
|
+
.set({
|
|
1918
|
+
status: "fulfilled",
|
|
1919
|
+
updatedAt: new Date(),
|
|
1920
|
+
})
|
|
1921
|
+
.where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, data.bookingItemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
|
|
1922
|
+
}
|
|
1923
|
+
await tx.insert(bookingActivityLog).values({
|
|
1924
|
+
bookingId,
|
|
1925
|
+
actorId: userId ?? "system",
|
|
1926
|
+
activityType: "redemption_recorded",
|
|
1927
|
+
description: "Booking redemption recorded",
|
|
1928
|
+
metadata: {
|
|
1929
|
+
redemptionEventId: event?.id ?? null,
|
|
1930
|
+
bookingItemId: data.bookingItemId ?? null,
|
|
1931
|
+
participantId: data.participantId ?? null,
|
|
1932
|
+
redeemedAt: redeemedAt.toISOString(),
|
|
1933
|
+
method: data.method,
|
|
1934
|
+
},
|
|
1935
|
+
});
|
|
1936
|
+
await syncTransactionOnBookingRedeemed(tx, bookingId);
|
|
1937
|
+
return event ?? null;
|
|
1938
|
+
});
|
|
1939
|
+
},
|
|
1940
|
+
listActivity(db, bookingId) {
|
|
1941
|
+
return db
|
|
1942
|
+
.select()
|
|
1943
|
+
.from(bookingActivityLog)
|
|
1944
|
+
.where(eq(bookingActivityLog.bookingId, bookingId))
|
|
1945
|
+
.orderBy(desc(bookingActivityLog.createdAt));
|
|
1946
|
+
},
|
|
1947
|
+
listNotes(db, bookingId) {
|
|
1948
|
+
return db
|
|
1949
|
+
.select()
|
|
1950
|
+
.from(bookingNotes)
|
|
1951
|
+
.where(eq(bookingNotes.bookingId, bookingId))
|
|
1952
|
+
.orderBy(bookingNotes.createdAt);
|
|
1953
|
+
},
|
|
1954
|
+
async createNote(db, bookingId, userId, data) {
|
|
1955
|
+
const [booking] = await db
|
|
1956
|
+
.select({ id: bookings.id })
|
|
1957
|
+
.from(bookings)
|
|
1958
|
+
.where(eq(bookings.id, bookingId))
|
|
1959
|
+
.limit(1);
|
|
1960
|
+
if (!booking) {
|
|
1961
|
+
return null;
|
|
1962
|
+
}
|
|
1963
|
+
const [row] = await db
|
|
1964
|
+
.insert(bookingNotes)
|
|
1965
|
+
.values({
|
|
1966
|
+
bookingId,
|
|
1967
|
+
authorId: userId,
|
|
1968
|
+
content: data.content,
|
|
1969
|
+
})
|
|
1970
|
+
.returning();
|
|
1971
|
+
await db.insert(bookingActivityLog).values({
|
|
1972
|
+
bookingId,
|
|
1973
|
+
actorId: userId,
|
|
1974
|
+
activityType: "note_added",
|
|
1975
|
+
description: "Note added",
|
|
1976
|
+
});
|
|
1977
|
+
return row;
|
|
1978
|
+
},
|
|
1979
|
+
listDocuments(db, bookingId) {
|
|
1980
|
+
return db
|
|
1981
|
+
.select()
|
|
1982
|
+
.from(bookingDocuments)
|
|
1983
|
+
.where(eq(bookingDocuments.bookingId, bookingId))
|
|
1984
|
+
.orderBy(bookingDocuments.createdAt);
|
|
1985
|
+
},
|
|
1986
|
+
async createDocument(db, bookingId, data) {
|
|
1987
|
+
const [booking] = await db
|
|
1988
|
+
.select({ id: bookings.id })
|
|
1989
|
+
.from(bookings)
|
|
1990
|
+
.where(eq(bookings.id, bookingId))
|
|
1991
|
+
.limit(1);
|
|
1992
|
+
if (!booking) {
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
const [row] = await db
|
|
1996
|
+
.insert(bookingDocuments)
|
|
1997
|
+
.values({
|
|
1998
|
+
bookingId,
|
|
1999
|
+
participantId: data.participantId ?? data.passengerId ?? null,
|
|
2000
|
+
type: data.type,
|
|
2001
|
+
fileName: data.fileName,
|
|
2002
|
+
fileUrl: data.fileUrl,
|
|
2003
|
+
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
|
|
2004
|
+
notes: data.notes ?? null,
|
|
2005
|
+
})
|
|
2006
|
+
.returning();
|
|
2007
|
+
return row;
|
|
2008
|
+
},
|
|
2009
|
+
async deleteDocument(db, documentId) {
|
|
2010
|
+
const [row] = await db
|
|
2011
|
+
.delete(bookingDocuments)
|
|
2012
|
+
.where(eq(bookingDocuments.id, documentId))
|
|
2013
|
+
.returning({ id: bookingDocuments.id });
|
|
2014
|
+
return row ?? null;
|
|
2015
|
+
},
|
|
2016
|
+
};
|