@voyantjs/bookings 0.119.2 → 0.119.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pricing-assignment/age.d.ts +33 -0
- package/dist/pricing-assignment/age.d.ts.map +1 -0
- package/dist/pricing-assignment/age.js +109 -0
- package/dist/pricing-assignment/draft.d.ts +69 -0
- package/dist/pricing-assignment/draft.d.ts.map +1 -0
- package/dist/pricing-assignment/draft.js +284 -0
- package/dist/pricing-assignment/types.d.ts +113 -0
- package/dist/pricing-assignment/types.d.ts.map +1 -0
- package/dist/pricing-assignment/types.js +30 -0
- package/dist/pricing-assignment/unit-helpers.d.ts +11 -0
- package/dist/pricing-assignment/unit-helpers.d.ts.map +1 -0
- package/dist/pricing-assignment/unit-helpers.js +19 -0
- package/dist/pricing-assignment/verify.d.ts +67 -0
- package/dist/pricing-assignment/verify.d.ts.map +1 -0
- package/dist/pricing-assignment/verify.js +121 -0
- package/dist/pricing-assignment.d.ts +4 -275
- package/dist/pricing-assignment.d.ts.map +1 -1
- package/dist/pricing-assignment.js +4 -559
- package/dist/routes-admin.d.ts +2894 -0
- package/dist/routes-admin.d.ts.map +1 -0
- package/dist/routes-admin.js +2111 -0
- package/dist/routes.d.ts +1 -2893
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +1 -2111
- package/dist/service-core.d.ts +6240 -0
- package/dist/service-core.d.ts.map +1 -0
- package/dist/service-core.js +4350 -0
- package/dist/service-public-core.d.ts +907 -0
- package/dist/service-public-core.d.ts.map +1 -0
- package/dist/service-public-core.js +1176 -0
- package/dist/service-public.d.ts +1 -906
- package/dist/service-public.d.ts.map +1 -1
- package/dist/service-public.js +1 -1176
- package/dist/service.d.ts +1 -6239
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1 -4350
- package/package.json +5 -5
package/dist/service.js
CHANGED
|
@@ -1,4350 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { appendActionLedgerMutation, } from "@voyantjs/action-ledger";
|
|
3
|
-
import { newId } from "@voyantjs/db/lib/typeid";
|
|
4
|
-
import { authUser } from "@voyantjs/db/schema/iam";
|
|
5
|
-
import { and, asc, desc, eq, exists, getTableColumns, gte, ilike, inArray, isNotNull, lte, ne, notInArray, or, sql, } from "drizzle-orm";
|
|
6
|
-
import { BOOKING_STATUS_CAPABILITIES } from "./action-ledger-capabilities.js";
|
|
7
|
-
import { availabilitySlotsRef } from "./availability-ref.js";
|
|
8
|
-
import { exchangeRatesRef } from "./markets-ref.js";
|
|
9
|
-
import { applyTravelDetailSnapshot, } from "./pii.js";
|
|
10
|
-
import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productCategoryProductsRef, productDayServicesRef, productDaysRef, productItinerariesRef, productOptionsRef, productsRef, productTicketSettingsRef, suppliersRef, } from "./products-ref.js";
|
|
11
|
-
import { bookingTravelerTravelDetails } from "./schema/travel-details.js";
|
|
12
|
-
import { bookingActivityLog, bookingAllocations, bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingNotes, bookingRedemptionEvents, bookingStaffAssignments, bookingSupplierStatuses, bookings, bookingTravelers, } from "./schema.js";
|
|
13
|
-
import { cleanupGroupOnBookingCancelled } from "./service-groups.js";
|
|
14
|
-
import { canTransitionBooking, transitionBooking } from "./state-machine.js";
|
|
15
|
-
import { bookingTransactionDetailsRef, offerItemParticipantsRef, offerItemsRef, offerParticipantsRef, offerStaffAssignmentsRef, offersRef, orderItemParticipantsRef, orderItemsRef, orderParticipantsRef, orderStaffAssignmentsRef, ordersRef, } from "./transactions-ref.js";
|
|
16
|
-
/**
|
|
17
|
-
* Emit `ARRAY[$1, $2, …]::text[]` instead of the naive
|
|
18
|
-
* `${jsArray}::text[]` form. drizzle's `sql` template spreads JS
|
|
19
|
-
* arrays into a row constructor (`($1, $2)`) which Postgres refuses
|
|
20
|
-
* to cast to `text[]` — see issue #952.
|
|
21
|
-
*/
|
|
22
|
-
function sqlTextArray(values) {
|
|
23
|
-
if (values.length === 0)
|
|
24
|
-
return sql `ARRAY[]::text[]`;
|
|
25
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
26
|
-
return sql `ARRAY[${sql.join(
|
|
27
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
28
|
-
values.map((value) => sql `${value}`), sql.raw(", "))}]::text[]`;
|
|
29
|
-
}
|
|
30
|
-
function buildBookingSearchCondition(search) {
|
|
31
|
-
const trimmed = search.trim();
|
|
32
|
-
if (!trimmed)
|
|
33
|
-
return undefined;
|
|
34
|
-
const term = `%${trimmed}%`;
|
|
35
|
-
const normalizedPhoneTerm = trimmed.replace(/\D/g, "");
|
|
36
|
-
const shouldSearchNormalizedPhone = normalizedPhoneTerm.length >= 7 && /^[+\d\s().-]+$/.test(trimmed);
|
|
37
|
-
const searchConditions = [
|
|
38
|
-
ilike(bookings.bookingNumber, term),
|
|
39
|
-
ilike(bookings.externalBookingRef, term),
|
|
40
|
-
ilike(bookings.internalNotes, term),
|
|
41
|
-
ilike(bookings.contactFirstName, term),
|
|
42
|
-
ilike(bookings.contactLastName, term),
|
|
43
|
-
ilike(bookings.contactTaxId, term),
|
|
44
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
45
|
-
sql `concat_ws(' ', ${bookings.contactFirstName}, ${bookings.contactLastName}) ilike ${term}`,
|
|
46
|
-
ilike(bookings.contactEmail, term),
|
|
47
|
-
ilike(bookings.contactPhone, term),
|
|
48
|
-
ilike(bookings.contactCountry, term),
|
|
49
|
-
ilike(bookings.contactRegion, term),
|
|
50
|
-
ilike(bookings.contactCity, term),
|
|
51
|
-
ilike(bookings.contactAddressLine1, term),
|
|
52
|
-
ilike(bookings.contactAddressLine2, term),
|
|
53
|
-
ilike(bookings.contactPostalCode, term),
|
|
54
|
-
// Match line-item title + product-name snapshot so operators can
|
|
55
|
-
// find "all bookings on Paris Sunset & Seine" from the search box
|
|
56
|
-
// instead of having to open the product filter popover.
|
|
57
|
-
sql `exists (
|
|
58
|
-
select 1 from ${bookingItems}
|
|
59
|
-
where ${bookingItems.bookingId} = ${bookings.id}
|
|
60
|
-
and (
|
|
61
|
-
${bookingItems.title} ilike ${term}
|
|
62
|
-
or ${bookingItems.productNameSnapshot} ilike ${term}
|
|
63
|
-
)
|
|
64
|
-
)`,
|
|
65
|
-
];
|
|
66
|
-
if (shouldSearchNormalizedPhone) {
|
|
67
|
-
searchConditions.push(
|
|
68
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
69
|
-
sql `regexp_replace(coalesce(${bookings.contactPhone}, ''), '[^0-9]+', '', 'g') like ${`%${normalizedPhoneTerm}%`}`);
|
|
70
|
-
}
|
|
71
|
-
return or(...searchConditions);
|
|
72
|
-
}
|
|
73
|
-
export function normalizeBookingBillingPartyUpdate(data) {
|
|
74
|
-
if (data.personId) {
|
|
75
|
-
return { ...data, organizationId: null };
|
|
76
|
-
}
|
|
77
|
-
if (data.organizationId) {
|
|
78
|
-
return { ...data, personId: null };
|
|
79
|
-
}
|
|
80
|
-
return data;
|
|
81
|
-
}
|
|
82
|
-
function pickTravelDetailFields(data) {
|
|
83
|
-
return {
|
|
84
|
-
nationality: data.nationality,
|
|
85
|
-
documentType: data.documentType,
|
|
86
|
-
documentNumber: data.documentNumber,
|
|
87
|
-
documentExpiry: data.documentExpiry,
|
|
88
|
-
documentIssuingCountry: data.documentIssuingCountry,
|
|
89
|
-
documentIssuingAuthority: data.documentIssuingAuthority,
|
|
90
|
-
documentPersonDocumentId: data.documentPersonDocumentId,
|
|
91
|
-
dateOfBirth: data.dateOfBirth,
|
|
92
|
-
dietaryRequirements: data.dietaryRequirements,
|
|
93
|
-
accessibilityNeeds: data.accessibilityNeeds,
|
|
94
|
-
isLeadTraveler: data.isLeadTraveler,
|
|
95
|
-
sharingGroupId: data.sharingGroupId,
|
|
96
|
-
roomTypeId: data.roomTypeId,
|
|
97
|
-
bedPreference: data.bedPreference,
|
|
98
|
-
allocations: data.allocations,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
function allocationStatusForBookingItemStatus(status) {
|
|
102
|
-
if (status === "confirmed")
|
|
103
|
-
return "confirmed";
|
|
104
|
-
if (status === "fulfilled")
|
|
105
|
-
return "fulfilled";
|
|
106
|
-
if (status === "cancelled")
|
|
107
|
-
return "cancelled";
|
|
108
|
-
if (status === "expired")
|
|
109
|
-
return "expired";
|
|
110
|
-
return "held";
|
|
111
|
-
}
|
|
112
|
-
function terminalBookingItemStatusForOverride(status) {
|
|
113
|
-
if (status === "cancelled")
|
|
114
|
-
return "cancelled";
|
|
115
|
-
if (status === "expired")
|
|
116
|
-
return "expired";
|
|
117
|
-
if (status === "completed")
|
|
118
|
-
return "fulfilled";
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
function terminalBookingAllocationStatusForOverride(status) {
|
|
122
|
-
if (status === "cancelled")
|
|
123
|
-
return "cancelled";
|
|
124
|
-
if (status === "expired")
|
|
125
|
-
return "expired";
|
|
126
|
-
if (status === "completed")
|
|
127
|
-
return "fulfilled";
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
function allocationStatusConsumesSlotCapacity(status) {
|
|
131
|
-
return status === "held" || status === "confirmed" || status === "fulfilled";
|
|
132
|
-
}
|
|
133
|
-
async function appendBookingStatusMutationLedger(db, runtime, input) {
|
|
134
|
-
if (!runtime.actionLedgerContext)
|
|
135
|
-
return;
|
|
136
|
-
await appendActionLedgerMutation(db, {
|
|
137
|
-
context: runtime.actionLedgerContext,
|
|
138
|
-
actionName: input.actionName,
|
|
139
|
-
actionVersion: "v1",
|
|
140
|
-
actionKind: "update",
|
|
141
|
-
status: "succeeded",
|
|
142
|
-
evaluatedRisk: input.evaluatedRisk ?? "medium",
|
|
143
|
-
targetType: "booking",
|
|
144
|
-
targetId: input.bookingId,
|
|
145
|
-
routeOrToolName: input.routeOrToolName,
|
|
146
|
-
capabilityId: input.capabilityId,
|
|
147
|
-
capabilityVersion: "v1",
|
|
148
|
-
authorizationSource: runtime.actionLedgerAuthorizationSource ?? "bookings.status.route",
|
|
149
|
-
causationActionId: runtime.actionLedgerCausationActionId ?? null,
|
|
150
|
-
approvalId: runtime.actionLedgerApprovalId ?? null,
|
|
151
|
-
idempotencyScope: runtime.actionLedgerIdempotencyScope ?? null,
|
|
152
|
-
idempotencyKey: runtime.actionLedgerIdempotencyKey ?? null,
|
|
153
|
-
idempotencyFingerprint: runtime.actionLedgerIdempotencyFingerprint ?? null,
|
|
154
|
-
mutationDetail: {
|
|
155
|
-
summary: `Booking status changed from ${input.fromStatus} to ${input.toStatus}`,
|
|
156
|
-
reversalKind: "none",
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
/** Stable string identifier for the event. */
|
|
161
|
-
export const AVAILABILITY_SLOT_CHANGED_EVENT = "availability.slot.changed";
|
|
162
|
-
/**
|
|
163
|
-
* Emit a batch of slot-change events through the runtime's EventBus.
|
|
164
|
-
* No-op when no event bus is wired (the common test path). Each emit is
|
|
165
|
-
* fire-and-forget per the EventBus contract — subscriber errors are
|
|
166
|
-
* logged, not rethrown — but we await to keep ordering deterministic in
|
|
167
|
-
* tests that drain the bus before assertions.
|
|
168
|
-
*/
|
|
169
|
-
async function emitSlotChanges(runtime, changes) {
|
|
170
|
-
const eventBus = runtime.eventBus;
|
|
171
|
-
if (!eventBus || changes.length === 0)
|
|
172
|
-
return;
|
|
173
|
-
for (const change of changes) {
|
|
174
|
-
await eventBus.emit(AVAILABILITY_SLOT_CHANGED_EVENT, change, {
|
|
175
|
-
category: "domain",
|
|
176
|
-
source: "service",
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const travelerParticipantTypes = ["traveler", "occupant"];
|
|
181
|
-
const sharingGroupBookingStatuses = [
|
|
182
|
-
"draft",
|
|
183
|
-
"on_hold",
|
|
184
|
-
"confirmed",
|
|
185
|
-
"in_progress",
|
|
186
|
-
"completed",
|
|
187
|
-
];
|
|
188
|
-
const sharingGroupAllocationStatuses = ["held", "confirmed", "fulfilled"];
|
|
189
|
-
class BookingServiceError extends Error {
|
|
190
|
-
code;
|
|
191
|
-
constructor(code, message) {
|
|
192
|
-
super(message ?? code);
|
|
193
|
-
this.code = code;
|
|
194
|
-
this.name = "BookingServiceError";
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
function toTimestamp(value) {
|
|
198
|
-
return value ? new Date(value) : null;
|
|
199
|
-
}
|
|
200
|
-
function confirmedAtForStatus(status, value, now = new Date()) {
|
|
201
|
-
if (status !== "confirmed")
|
|
202
|
-
return null;
|
|
203
|
-
return value ?? now;
|
|
204
|
-
}
|
|
205
|
-
function confirmedAtForBookingUpdate(currentStatus, data, now = new Date()) {
|
|
206
|
-
const nextStatus = data.status ?? currentStatus;
|
|
207
|
-
if (nextStatus !== "confirmed")
|
|
208
|
-
return null;
|
|
209
|
-
if (data.confirmedAt !== undefined)
|
|
210
|
-
return confirmedAtForStatus(nextStatus, toTimestamp(data.confirmedAt), now);
|
|
211
|
-
return currentStatus === "confirmed" ? undefined : now;
|
|
212
|
-
}
|
|
213
|
-
function toDateValue(value) {
|
|
214
|
-
return value instanceof Date ? value : new Date(value);
|
|
215
|
-
}
|
|
216
|
-
function toDateValueOrNull(value) {
|
|
217
|
-
if (!value)
|
|
218
|
-
return null;
|
|
219
|
-
return value instanceof Date ? value : new Date(value);
|
|
220
|
-
}
|
|
221
|
-
function toTravelerResponse(participant) {
|
|
222
|
-
return {
|
|
223
|
-
id: participant.id,
|
|
224
|
-
bookingId: participant.bookingId,
|
|
225
|
-
participantType: participant.participantType,
|
|
226
|
-
travelerCategory: participant.travelerCategory,
|
|
227
|
-
firstName: participant.firstName,
|
|
228
|
-
lastName: participant.lastName,
|
|
229
|
-
email: participant.email,
|
|
230
|
-
phone: participant.phone,
|
|
231
|
-
preferredLanguage: participant.preferredLanguage,
|
|
232
|
-
specialRequests: participant.specialRequests,
|
|
233
|
-
isPrimary: participant.isPrimary,
|
|
234
|
-
notes: participant.notes,
|
|
235
|
-
createdAt: participant.createdAt,
|
|
236
|
-
updatedAt: participant.updatedAt,
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
function normalizeTravelerAllocationMap(value) {
|
|
240
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
241
|
-
return {};
|
|
242
|
-
const out = {};
|
|
243
|
-
for (const [key, raw] of Object.entries(value)) {
|
|
244
|
-
if (typeof raw === "string")
|
|
245
|
-
out[key] = raw;
|
|
246
|
-
}
|
|
247
|
-
return out;
|
|
248
|
-
}
|
|
249
|
-
async function ensureParticipantFlags(db, bookingId, travelerId, data) {
|
|
250
|
-
if (data.isPrimary) {
|
|
251
|
-
await db
|
|
252
|
-
.update(bookingTravelers)
|
|
253
|
-
.set({ isPrimary: false, updatedAt: new Date() })
|
|
254
|
-
.where(and(eq(bookingTravelers.bookingId, bookingId), ne(bookingTravelers.id, travelerId)));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
async function ensureBookingScopedLinks(db, bookingId, data) {
|
|
258
|
-
if (data.bookingItemId) {
|
|
259
|
-
const [item] = await db
|
|
260
|
-
.select({ id: bookingItems.id })
|
|
261
|
-
.from(bookingItems)
|
|
262
|
-
.where(and(eq(bookingItems.id, data.bookingItemId), eq(bookingItems.bookingId, bookingId)))
|
|
263
|
-
.limit(1);
|
|
264
|
-
if (!item) {
|
|
265
|
-
return { ok: false, reason: "booking_item_not_found" };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (data.travelerId) {
|
|
269
|
-
const [traveler] = await db
|
|
270
|
-
.select({ id: bookingTravelers.id })
|
|
271
|
-
.from(bookingTravelers)
|
|
272
|
-
.where(and(eq(bookingTravelers.id, data.travelerId), eq(bookingTravelers.bookingId, bookingId)))
|
|
273
|
-
.limit(1);
|
|
274
|
-
if (!traveler) {
|
|
275
|
-
return { ok: false, reason: "traveler_not_found" };
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return { ok: true };
|
|
279
|
-
}
|
|
280
|
-
function isStaffParticipantType(participantType) {
|
|
281
|
-
return participantType === "staff";
|
|
282
|
-
}
|
|
283
|
-
function toStaffAssignmentRole(role) {
|
|
284
|
-
return role === "service_assignee" ? "service_assignee" : "other";
|
|
285
|
-
}
|
|
286
|
-
function deriveBookingDateRange(items) {
|
|
287
|
-
const dates = items
|
|
288
|
-
.flatMap((item) => [item.serviceDate, item.startsAt?.toISOString().slice(0, 10) ?? null])
|
|
289
|
-
.filter((value) => Boolean(value))
|
|
290
|
-
.sort();
|
|
291
|
-
return {
|
|
292
|
-
startDate: dates[0] ?? null,
|
|
293
|
-
endDate: dates[dates.length - 1] ?? null,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
function deriveBookingPax(participants, items) {
|
|
297
|
-
const pax = participants.filter((participant) => ["traveler", "occupant"].includes(participant.participantType)).length;
|
|
298
|
-
if (pax > 0) {
|
|
299
|
-
return pax;
|
|
300
|
-
}
|
|
301
|
-
return items
|
|
302
|
-
.filter((item) => item.itemType === "unit")
|
|
303
|
-
.reduce((sum, item) => sum + item.quantity, 0);
|
|
304
|
-
}
|
|
305
|
-
function getTransactionItemParticipantItemId(link) {
|
|
306
|
-
return "offerItemId" in link ? link.offerItemId : link.orderItemId;
|
|
307
|
-
}
|
|
308
|
-
function toStaffReservationParticipant(assignment, suffix) {
|
|
309
|
-
return {
|
|
310
|
-
id: `staff:${suffix}:${assignment.id}`,
|
|
311
|
-
personId: assignment.personId,
|
|
312
|
-
participantType: "staff",
|
|
313
|
-
travelerCategory: null,
|
|
314
|
-
firstName: assignment.firstName,
|
|
315
|
-
lastName: assignment.lastName,
|
|
316
|
-
email: assignment.email,
|
|
317
|
-
phone: assignment.phone,
|
|
318
|
-
preferredLanguage: assignment.preferredLanguage,
|
|
319
|
-
isPrimary: assignment.isPrimary,
|
|
320
|
-
notes: assignment.notes,
|
|
321
|
-
createdAt: new Date(),
|
|
322
|
-
updatedAt: new Date(),
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
function mapDeliveryFormatToFulfillment(format) {
|
|
326
|
-
switch (format) {
|
|
327
|
-
case "pdf":
|
|
328
|
-
return { fulfillmentType: "pdf", deliveryChannel: "download" };
|
|
329
|
-
case "qr_code":
|
|
330
|
-
return { fulfillmentType: "qr_code", deliveryChannel: "download" };
|
|
331
|
-
case "barcode":
|
|
332
|
-
return { fulfillmentType: "barcode", deliveryChannel: "download" };
|
|
333
|
-
case "mobile":
|
|
334
|
-
return { fulfillmentType: "mobile", deliveryChannel: "wallet" };
|
|
335
|
-
case "email":
|
|
336
|
-
return { fulfillmentType: "voucher", deliveryChannel: "email" };
|
|
337
|
-
case "ticket":
|
|
338
|
-
return { fulfillmentType: "ticket", deliveryChannel: "download" };
|
|
339
|
-
default:
|
|
340
|
-
return { fulfillmentType: "voucher", deliveryChannel: "download" };
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
async function getConvertProductData(db, data) {
|
|
344
|
-
const [product] = await db
|
|
345
|
-
.select()
|
|
346
|
-
.from(productsRef)
|
|
347
|
-
.where(eq(productsRef.id, data.productId))
|
|
348
|
-
.limit(1);
|
|
349
|
-
if (!product) {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
const itemLines = data.itemLines ?? [];
|
|
353
|
-
const requestedLineOptionIds = [
|
|
354
|
-
...new Set(itemLines
|
|
355
|
-
.map((line) => line.optionId ?? null)
|
|
356
|
-
.filter((optionId) => optionId !== null)),
|
|
357
|
-
];
|
|
358
|
-
const requestedUnitIds = [...new Set(itemLines.map((line) => line.optionUnitId))];
|
|
359
|
-
let lineOptions = [];
|
|
360
|
-
if (requestedLineOptionIds.length > 0) {
|
|
361
|
-
lineOptions = await db
|
|
362
|
-
.select()
|
|
363
|
-
.from(productOptionsRef)
|
|
364
|
-
.where(and(eq(productOptionsRef.productId, product.id), inArray(productOptionsRef.id, requestedLineOptionIds)));
|
|
365
|
-
if (lineOptions.length !== requestedLineOptionIds.length) {
|
|
366
|
-
return null;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
let option = null;
|
|
370
|
-
if (data.optionId) {
|
|
371
|
-
const [selectedOption] = await db
|
|
372
|
-
.select()
|
|
373
|
-
.from(productOptionsRef)
|
|
374
|
-
.where(and(eq(productOptionsRef.id, data.optionId), eq(productOptionsRef.productId, product.id)))
|
|
375
|
-
.limit(1);
|
|
376
|
-
if (!selectedOption) {
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
option = selectedOption;
|
|
380
|
-
}
|
|
381
|
-
else if (requestedLineOptionIds.length === 1) {
|
|
382
|
-
option = lineOptions[0] ?? null;
|
|
383
|
-
}
|
|
384
|
-
else if (requestedLineOptionIds.length > 1) {
|
|
385
|
-
option = null;
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
const [defaultOption] = await db
|
|
389
|
-
.select()
|
|
390
|
-
.from(productOptionsRef)
|
|
391
|
-
.where(eq(productOptionsRef.productId, product.id))
|
|
392
|
-
.orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder), asc(productOptionsRef.createdAt))
|
|
393
|
-
.limit(1);
|
|
394
|
-
option = defaultOption ?? null;
|
|
395
|
-
}
|
|
396
|
-
// product_days is keyed by itinerary_id (products re-parented days onto
|
|
397
|
-
// product_itineraries); getConvertProductData joins through the itinerary
|
|
398
|
-
// ref so the per-product day lookup still works for converts that want to
|
|
399
|
-
// seed booking supplier statuses from the product's day services.
|
|
400
|
-
const days = await db
|
|
401
|
-
.select({ id: productDaysRef.id, dayNumber: productDaysRef.dayNumber })
|
|
402
|
-
.from(productDaysRef)
|
|
403
|
-
.innerJoin(productItinerariesRef, eq(productDaysRef.itineraryId, productItinerariesRef.id))
|
|
404
|
-
.where(eq(productItinerariesRef.productId, product.id))
|
|
405
|
-
.orderBy(asc(productDaysRef.dayNumber));
|
|
406
|
-
const dayServices = days.length
|
|
407
|
-
? await db
|
|
408
|
-
.select({
|
|
409
|
-
supplierServiceId: productDayServicesRef.supplierServiceId,
|
|
410
|
-
name: productDayServicesRef.name,
|
|
411
|
-
costCurrency: productDayServicesRef.costCurrency,
|
|
412
|
-
costAmountCents: productDayServicesRef.costAmountCents,
|
|
413
|
-
})
|
|
414
|
-
.from(productDayServicesRef)
|
|
415
|
-
.where(
|
|
416
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
417
|
-
sql `${productDayServicesRef.dayId} IN (
|
|
418
|
-
SELECT ${productDaysRef.id}
|
|
419
|
-
FROM ${productDaysRef}
|
|
420
|
-
INNER JOIN ${productItinerariesRef}
|
|
421
|
-
ON ${productDaysRef.itineraryId} = ${productItinerariesRef.id}
|
|
422
|
-
WHERE ${productItinerariesRef.productId} = ${product.id}
|
|
423
|
-
)`)
|
|
424
|
-
.orderBy(asc(productDayServicesRef.sortOrder), asc(productDayServicesRef.id))
|
|
425
|
-
: [];
|
|
426
|
-
let units = [];
|
|
427
|
-
if (requestedUnitIds.length > 0) {
|
|
428
|
-
const unitRows = await db
|
|
429
|
-
.select({ unit: optionUnitsRef })
|
|
430
|
-
.from(optionUnitsRef)
|
|
431
|
-
.innerJoin(productOptionsRef, eq(optionUnitsRef.optionId, productOptionsRef.id))
|
|
432
|
-
.where(and(eq(productOptionsRef.productId, product.id), inArray(optionUnitsRef.id, requestedUnitIds)))
|
|
433
|
-
.orderBy(asc(optionUnitsRef.sortOrder), asc(optionUnitsRef.createdAt));
|
|
434
|
-
units = unitRows.map((row) => row.unit);
|
|
435
|
-
}
|
|
436
|
-
else if (option !== null) {
|
|
437
|
-
units = await db
|
|
438
|
-
.select()
|
|
439
|
-
.from(optionUnitsRef)
|
|
440
|
-
.where(eq(optionUnitsRef.optionId, option.id))
|
|
441
|
-
.orderBy(asc(optionUnitsRef.sortOrder), asc(optionUnitsRef.createdAt));
|
|
442
|
-
}
|
|
443
|
-
let slot = null;
|
|
444
|
-
if (data.slotId) {
|
|
445
|
-
const [selectedSlot] = await db
|
|
446
|
-
.select()
|
|
447
|
-
.from(availabilitySlotsRef)
|
|
448
|
-
.where(and(eq(availabilitySlotsRef.id, data.slotId), eq(availabilitySlotsRef.productId, product.id)))
|
|
449
|
-
.limit(1);
|
|
450
|
-
if (!selectedSlot) {
|
|
451
|
-
return null;
|
|
452
|
-
}
|
|
453
|
-
if (option && selectedSlot.optionId && selectedSlot.optionId !== option.id) {
|
|
454
|
-
return null;
|
|
455
|
-
}
|
|
456
|
-
// Note: per-line `optionId` is intentionally NOT required to match
|
|
457
|
-
// `selectedSlot.optionId`. A slot's `option_id` marks which option
|
|
458
|
-
// owns the slot's per-unit allocation tracking; other options on the
|
|
459
|
-
// same product are still bookable on the same departure (e.g. a
|
|
460
|
-
// tour-operator bus + hotel allotment selling SGL/DBL/TWN/TPL on one
|
|
461
|
-
// slot). Line options are validated to live on the product above
|
|
462
|
-
// (the `lineOptions.length !== requestedLineOptionIds.length` guard),
|
|
463
|
-
// and `adjustSlotCapacity` enforces total pax server-side at booking
|
|
464
|
-
// time. See issue #960.
|
|
465
|
-
slot = {
|
|
466
|
-
id: selectedSlot.id,
|
|
467
|
-
dateLocal: selectedSlot.dateLocal,
|
|
468
|
-
startsAt: selectedSlot.startsAt,
|
|
469
|
-
endsAt: selectedSlot.endsAt,
|
|
470
|
-
timezone: selectedSlot.timezone,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
return {
|
|
474
|
-
product: {
|
|
475
|
-
id: product.id,
|
|
476
|
-
name: product.name,
|
|
477
|
-
description: product.description,
|
|
478
|
-
sellCurrency: product.sellCurrency,
|
|
479
|
-
sellAmountCents: product.sellAmountCents,
|
|
480
|
-
costAmountCents: product.costAmountCents,
|
|
481
|
-
marginPercent: product.marginPercent,
|
|
482
|
-
startDate: product.startDate,
|
|
483
|
-
endDate: product.endDate,
|
|
484
|
-
pax: product.pax,
|
|
485
|
-
},
|
|
486
|
-
option: option ? { id: option.id, name: option.name } : null,
|
|
487
|
-
slot,
|
|
488
|
-
dayServices,
|
|
489
|
-
units: units.map((unit) => ({
|
|
490
|
-
id: unit.id,
|
|
491
|
-
optionId: unit.optionId,
|
|
492
|
-
name: unit.name,
|
|
493
|
-
description: unit.description,
|
|
494
|
-
unitType: unit.unitType,
|
|
495
|
-
isRequired: unit.isRequired,
|
|
496
|
-
minQuantity: unit.minQuantity,
|
|
497
|
-
sortOrder: unit.sortOrder,
|
|
498
|
-
})),
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
const DEFAULT_HOLD_MINUTES = 30;
|
|
502
|
-
function positiveHoldMinutes(value) {
|
|
503
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
504
|
-
}
|
|
505
|
-
function isUndefinedTableError(error) {
|
|
506
|
-
return (typeof error === "object" &&
|
|
507
|
-
error !== null &&
|
|
508
|
-
"code" in error &&
|
|
509
|
-
error.code === "42P01");
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Catalog enrichment for a booking item, taken at item-create time so
|
|
513
|
-
* the snapshot is authoritative. Each lookup is independently
|
|
514
|
-
* try/catch'd so a missing catalog/availability table (catalog-less
|
|
515
|
-
* OTA deployment) just yields `null` for that piece — the caller's
|
|
516
|
-
* explicit values still win.
|
|
517
|
-
*/
|
|
518
|
-
async function resolveBookingItemSnapshot(db, input) {
|
|
519
|
-
const result = {
|
|
520
|
-
productName: null,
|
|
521
|
-
optionName: null,
|
|
522
|
-
unitName: null,
|
|
523
|
-
departureLabel: null,
|
|
524
|
-
startsAt: null,
|
|
525
|
-
endsAt: null,
|
|
526
|
-
serviceDate: null,
|
|
527
|
-
};
|
|
528
|
-
if (input.productId) {
|
|
529
|
-
try {
|
|
530
|
-
const [row] = await db
|
|
531
|
-
.select({ name: productsRef.name })
|
|
532
|
-
.from(productsRef)
|
|
533
|
-
.where(eq(productsRef.id, input.productId))
|
|
534
|
-
.limit(1);
|
|
535
|
-
if (row)
|
|
536
|
-
result.productName = row.name;
|
|
537
|
-
}
|
|
538
|
-
catch (error) {
|
|
539
|
-
if (!isUndefinedTableError(error))
|
|
540
|
-
throw error;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
if (input.optionId) {
|
|
544
|
-
try {
|
|
545
|
-
const [row] = await db
|
|
546
|
-
.select({ name: productOptionsRef.name })
|
|
547
|
-
.from(productOptionsRef)
|
|
548
|
-
.where(eq(productOptionsRef.id, input.optionId))
|
|
549
|
-
.limit(1);
|
|
550
|
-
if (row)
|
|
551
|
-
result.optionName = row.name;
|
|
552
|
-
}
|
|
553
|
-
catch (error) {
|
|
554
|
-
if (!isUndefinedTableError(error))
|
|
555
|
-
throw error;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
if (input.optionUnitId) {
|
|
559
|
-
try {
|
|
560
|
-
const [row] = await db
|
|
561
|
-
.select({ name: optionUnitsRef.name })
|
|
562
|
-
.from(optionUnitsRef)
|
|
563
|
-
.where(eq(optionUnitsRef.id, input.optionUnitId))
|
|
564
|
-
.limit(1);
|
|
565
|
-
if (row)
|
|
566
|
-
result.unitName = row.name;
|
|
567
|
-
}
|
|
568
|
-
catch (error) {
|
|
569
|
-
if (!isUndefinedTableError(error))
|
|
570
|
-
throw error;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
if (input.availabilitySlotId) {
|
|
574
|
-
try {
|
|
575
|
-
const [slot] = await db
|
|
576
|
-
.select({
|
|
577
|
-
startsAt: availabilitySlotsRef.startsAt,
|
|
578
|
-
endsAt: availabilitySlotsRef.endsAt,
|
|
579
|
-
dateLocal: availabilitySlotsRef.dateLocal,
|
|
580
|
-
timezone: availabilitySlotsRef.timezone,
|
|
581
|
-
})
|
|
582
|
-
.from(availabilitySlotsRef)
|
|
583
|
-
.where(eq(availabilitySlotsRef.id, input.availabilitySlotId))
|
|
584
|
-
.limit(1);
|
|
585
|
-
if (slot) {
|
|
586
|
-
result.startsAt = slot.startsAt;
|
|
587
|
-
result.endsAt = slot.endsAt;
|
|
588
|
-
result.serviceDate = slot.dateLocal;
|
|
589
|
-
result.departureLabel = formatDepartureLabel(slot.startsAt, slot.timezone);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
catch (error) {
|
|
593
|
-
if (!isUndefinedTableError(error))
|
|
594
|
-
throw error;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return result;
|
|
598
|
-
}
|
|
599
|
-
function formatDepartureLabel(startsAt, timezone) {
|
|
600
|
-
if (!startsAt)
|
|
601
|
-
return null;
|
|
602
|
-
try {
|
|
603
|
-
const formatter = new Intl.DateTimeFormat("en", {
|
|
604
|
-
year: "numeric",
|
|
605
|
-
month: "short",
|
|
606
|
-
day: "numeric",
|
|
607
|
-
hour: "2-digit",
|
|
608
|
-
minute: "2-digit",
|
|
609
|
-
timeZone: timezone ?? undefined,
|
|
610
|
-
timeZoneName: timezone ? "short" : undefined,
|
|
611
|
-
});
|
|
612
|
-
return formatter.format(startsAt);
|
|
613
|
-
}
|
|
614
|
-
catch {
|
|
615
|
-
return startsAt.toISOString();
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
async function resolvePolicyHoldMinutes(db, items) {
|
|
619
|
-
const productIds = new Set();
|
|
620
|
-
const slotIds = new Set();
|
|
621
|
-
for (const item of items) {
|
|
622
|
-
if (item.productId)
|
|
623
|
-
productIds.add(item.productId);
|
|
624
|
-
const slotId = item.availabilitySlotId ?? item.slotId;
|
|
625
|
-
if (slotId)
|
|
626
|
-
slotIds.add(slotId);
|
|
627
|
-
}
|
|
628
|
-
if (slotIds.size > 0) {
|
|
629
|
-
const slotRows = await db
|
|
630
|
-
.select({ productId: availabilitySlotsRef.productId })
|
|
631
|
-
.from(availabilitySlotsRef)
|
|
632
|
-
.where(inArray(availabilitySlotsRef.id, [...slotIds]));
|
|
633
|
-
for (const slot of slotRows) {
|
|
634
|
-
if (slot.productId)
|
|
635
|
-
productIds.add(slot.productId);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
if (productIds.size === 0) {
|
|
639
|
-
return DEFAULT_HOLD_MINUTES;
|
|
640
|
-
}
|
|
641
|
-
const productRows = await db
|
|
642
|
-
.select({
|
|
643
|
-
id: productsRef.id,
|
|
644
|
-
supplierId: productsRef.supplierId,
|
|
645
|
-
reservationTimeoutMinutes: productsRef.reservationTimeoutMinutes,
|
|
646
|
-
})
|
|
647
|
-
.from(productsRef)
|
|
648
|
-
.where(inArray(productsRef.id, [...productIds]))
|
|
649
|
-
.catch((error) => {
|
|
650
|
-
if (isUndefinedTableError(error))
|
|
651
|
-
return [];
|
|
652
|
-
throw error;
|
|
653
|
-
});
|
|
654
|
-
const supplierIds = [
|
|
655
|
-
...new Set(productRows.map((product) => product.supplierId).filter(Boolean)),
|
|
656
|
-
];
|
|
657
|
-
const supplierRows = supplierIds.length > 0
|
|
658
|
-
? await db
|
|
659
|
-
.select({
|
|
660
|
-
id: suppliersRef.id,
|
|
661
|
-
reservationTimeoutMinutes: suppliersRef.reservationTimeoutMinutes,
|
|
662
|
-
})
|
|
663
|
-
.from(suppliersRef)
|
|
664
|
-
.where(inArray(suppliersRef.id, supplierIds))
|
|
665
|
-
.catch((error) => {
|
|
666
|
-
if (isUndefinedTableError(error))
|
|
667
|
-
return [];
|
|
668
|
-
throw error;
|
|
669
|
-
})
|
|
670
|
-
: [];
|
|
671
|
-
const supplierTimeouts = new Map(supplierRows.map((supplier) => [
|
|
672
|
-
supplier.id,
|
|
673
|
-
positiveHoldMinutes(supplier.reservationTimeoutMinutes),
|
|
674
|
-
]));
|
|
675
|
-
const candidates = [];
|
|
676
|
-
for (const product of productRows) {
|
|
677
|
-
const productMinutes = positiveHoldMinutes(product.reservationTimeoutMinutes);
|
|
678
|
-
if (productMinutes !== null) {
|
|
679
|
-
candidates.push(productMinutes);
|
|
680
|
-
continue;
|
|
681
|
-
}
|
|
682
|
-
const supplierMinutes = product.supplierId ? supplierTimeouts.get(product.supplierId) : null;
|
|
683
|
-
if (supplierMinutes !== null && supplierMinutes !== undefined) {
|
|
684
|
-
candidates.push(supplierMinutes);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_HOLD_MINUTES;
|
|
688
|
-
}
|
|
689
|
-
async function listBookingItemsForSummaries(db, bookingIds) {
|
|
690
|
-
if (bookingIds.length === 0)
|
|
691
|
-
return [];
|
|
692
|
-
// `productName` prefers the snapshot (authoritative — what was sold)
|
|
693
|
-
// and falls back to the current catalog name only when the snapshot
|
|
694
|
-
// is missing (legacy rows pre-dating the snapshot columns). Same
|
|
695
|
-
// catalog-less fallback as before via the 42P01 catch.
|
|
696
|
-
return db
|
|
697
|
-
.select({
|
|
698
|
-
id: bookingItems.id,
|
|
699
|
-
bookingId: bookingItems.bookingId,
|
|
700
|
-
title: bookingItems.title,
|
|
701
|
-
itemType: bookingItems.itemType,
|
|
702
|
-
productId: bookingItems.productId,
|
|
703
|
-
productName: sql `coalesce(${bookingItems.productNameSnapshot}, ${productsRef.name})`,
|
|
704
|
-
startsAt: bookingItems.startsAt,
|
|
705
|
-
endsAt: bookingItems.endsAt,
|
|
706
|
-
})
|
|
707
|
-
.from(bookingItems)
|
|
708
|
-
.leftJoin(productsRef, eq(productsRef.id, bookingItems.productId))
|
|
709
|
-
.where(inArray(bookingItems.bookingId, bookingIds))
|
|
710
|
-
.orderBy(asc(bookingItems.createdAt))
|
|
711
|
-
.catch(async (error) => {
|
|
712
|
-
if (!isUndefinedTableError(error))
|
|
713
|
-
throw error;
|
|
714
|
-
return db
|
|
715
|
-
.select({
|
|
716
|
-
id: bookingItems.id,
|
|
717
|
-
bookingId: bookingItems.bookingId,
|
|
718
|
-
title: bookingItems.title,
|
|
719
|
-
itemType: bookingItems.itemType,
|
|
720
|
-
productId: bookingItems.productId,
|
|
721
|
-
productName: bookingItems.productNameSnapshot,
|
|
722
|
-
startsAt: bookingItems.startsAt,
|
|
723
|
-
endsAt: bookingItems.endsAt,
|
|
724
|
-
})
|
|
725
|
-
.from(bookingItems)
|
|
726
|
-
.where(inArray(bookingItems.bookingId, bookingIds))
|
|
727
|
-
.orderBy(asc(bookingItems.createdAt));
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
async function computeHoldExpiresAt(db, input, items = []) {
|
|
731
|
-
if (input.holdExpiresAt) {
|
|
732
|
-
return new Date(input.holdExpiresAt);
|
|
733
|
-
}
|
|
734
|
-
const minutes = input.holdMinutes ?? (await resolvePolicyHoldMinutes(db, items));
|
|
735
|
-
return new Date(Date.now() + minutes * 60 * 1000);
|
|
736
|
-
}
|
|
737
|
-
/**
|
|
738
|
-
* Walk a booking's items, convert each line into the booking's
|
|
739
|
-
* `baseCurrency` via the booking's `fxRateSetId`, sum.
|
|
740
|
-
*
|
|
741
|
-
* Returns:
|
|
742
|
-
* - `{ status: "ok", baseSellAmountCents, baseCostAmountCents }` when
|
|
743
|
-
* every item's currency was either already in `baseCurrency` or had
|
|
744
|
-
* a rate row in the rate set
|
|
745
|
-
* - `{ status: "missing_rate", currency }` when an item's
|
|
746
|
-
* `sellCurrency` had no rate in the rate set; caller treats as
|
|
747
|
-
* "leave base totals untouched, surface to ops"
|
|
748
|
-
* - `{ status: "skipped" }` when the booking has no `fxRateSetId`
|
|
749
|
-
* (multi-currency conversion isn't possible without one)
|
|
750
|
-
*
|
|
751
|
-
* Pure conversion math. Caller controls persistence.
|
|
752
|
-
*/
|
|
753
|
-
async function rollupBaseTotals(db, bookingId, baseCurrency) {
|
|
754
|
-
const [booking] = await db
|
|
755
|
-
.select({ fxRateSetId: bookings.fxRateSetId })
|
|
756
|
-
.from(bookings)
|
|
757
|
-
.where(eq(bookings.id, bookingId))
|
|
758
|
-
.limit(1);
|
|
759
|
-
if (!booking?.fxRateSetId) {
|
|
760
|
-
return { status: "skipped" };
|
|
761
|
-
}
|
|
762
|
-
// Cache for the closure — TypeScript can't narrow `booking` after
|
|
763
|
-
// the closure boundary, so capture the id in a local.
|
|
764
|
-
const fxRateSetId = booking.fxRateSetId;
|
|
765
|
-
const items = await db
|
|
766
|
-
.select({
|
|
767
|
-
sellCurrency: bookingItems.sellCurrency,
|
|
768
|
-
totalSellAmountCents: bookingItems.totalSellAmountCents,
|
|
769
|
-
costCurrency: bookingItems.costCurrency,
|
|
770
|
-
totalCostAmountCents: bookingItems.totalCostAmountCents,
|
|
771
|
-
})
|
|
772
|
-
.from(bookingItems)
|
|
773
|
-
.where(eq(bookingItems.bookingId, bookingId));
|
|
774
|
-
// Cache rates we look up to avoid N+1 within one booking.
|
|
775
|
-
const rateCache = new Map(); // key: `${from}->${to}`, value: decimal rate or null
|
|
776
|
-
async function rate(from, to) {
|
|
777
|
-
if (from === to)
|
|
778
|
-
return 1;
|
|
779
|
-
const key = `${from}->${to}`;
|
|
780
|
-
if (rateCache.has(key))
|
|
781
|
-
return rateCache.get(key) ?? null;
|
|
782
|
-
const [direct] = await db
|
|
783
|
-
.select({ rate: exchangeRatesRef.rateDecimal })
|
|
784
|
-
.from(exchangeRatesRef)
|
|
785
|
-
.where(and(eq(exchangeRatesRef.fxRateSetId, fxRateSetId), eq(exchangeRatesRef.baseCurrency, from), eq(exchangeRatesRef.quoteCurrency, to)))
|
|
786
|
-
.limit(1);
|
|
787
|
-
if (direct) {
|
|
788
|
-
const value = Number.parseFloat(direct.rate);
|
|
789
|
-
rateCache.set(key, value);
|
|
790
|
-
return value;
|
|
791
|
-
}
|
|
792
|
-
// Try the inverse
|
|
793
|
-
const [inverse] = await db
|
|
794
|
-
.select({ rate: exchangeRatesRef.inverseRateDecimal })
|
|
795
|
-
.from(exchangeRatesRef)
|
|
796
|
-
.where(and(eq(exchangeRatesRef.fxRateSetId, fxRateSetId), eq(exchangeRatesRef.baseCurrency, to), eq(exchangeRatesRef.quoteCurrency, from)))
|
|
797
|
-
.limit(1);
|
|
798
|
-
if (inverse?.rate) {
|
|
799
|
-
const value = Number.parseFloat(inverse.rate);
|
|
800
|
-
rateCache.set(key, value);
|
|
801
|
-
return value;
|
|
802
|
-
}
|
|
803
|
-
rateCache.set(key, null);
|
|
804
|
-
return null;
|
|
805
|
-
}
|
|
806
|
-
let baseSellAmountCents = 0;
|
|
807
|
-
let baseCostAmountCents = 0;
|
|
808
|
-
for (const item of items) {
|
|
809
|
-
if (item.totalSellAmountCents !== null) {
|
|
810
|
-
const r = await rate(item.sellCurrency, baseCurrency);
|
|
811
|
-
if (r === null) {
|
|
812
|
-
return { status: "missing_rate", currency: item.sellCurrency };
|
|
813
|
-
}
|
|
814
|
-
baseSellAmountCents += Math.round(item.totalSellAmountCents * r);
|
|
815
|
-
}
|
|
816
|
-
if (item.totalCostAmountCents !== null && item.costCurrency) {
|
|
817
|
-
const r = await rate(item.costCurrency, baseCurrency);
|
|
818
|
-
if (r === null) {
|
|
819
|
-
return { status: "missing_rate", currency: item.costCurrency };
|
|
820
|
-
}
|
|
821
|
-
baseCostAmountCents += Math.round(item.totalCostAmountCents * r);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
return { status: "ok", baseSellAmountCents, baseCostAmountCents };
|
|
825
|
-
}
|
|
826
|
-
async function lockAvailabilitySlot(db, slotId) {
|
|
827
|
-
const rows = await db.execute(sql `SELECT id, product_id, option_id, date_local, starts_at, ends_at, timezone, status, unlimited, remaining_pax
|
|
828
|
-
FROM ${availabilitySlotsRef}
|
|
829
|
-
WHERE ${availabilitySlotsRef.id} = ${slotId}
|
|
830
|
-
FOR UPDATE`);
|
|
831
|
-
const row = toRows(rows)[0];
|
|
832
|
-
if (!row) {
|
|
833
|
-
return null;
|
|
834
|
-
}
|
|
835
|
-
return {
|
|
836
|
-
...row,
|
|
837
|
-
starts_at: toDateValue(row.starts_at),
|
|
838
|
-
ends_at: toDateValueOrNull(row.ends_at),
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
function buildSlotChange(slot, remainingPax, source) {
|
|
842
|
-
return {
|
|
843
|
-
slotId: slot.id,
|
|
844
|
-
productId: slot.product_id,
|
|
845
|
-
optionId: slot.option_id,
|
|
846
|
-
startsAt: slot.starts_at,
|
|
847
|
-
remainingPax: slot.unlimited ? null : remainingPax,
|
|
848
|
-
unlimited: slot.unlimited,
|
|
849
|
-
source,
|
|
850
|
-
};
|
|
851
|
-
}
|
|
852
|
-
async function adjustSlotCapacity(db, slotId, delta, source = "booking") {
|
|
853
|
-
const locked = await lockAvailabilitySlot(db, slotId);
|
|
854
|
-
if (!locked) {
|
|
855
|
-
return { status: "slot_not_found" };
|
|
856
|
-
}
|
|
857
|
-
if (locked.status !== "open" && locked.status !== "sold_out") {
|
|
858
|
-
return { status: "slot_unavailable", slot: locked };
|
|
859
|
-
}
|
|
860
|
-
if (locked.unlimited) {
|
|
861
|
-
return {
|
|
862
|
-
status: "ok",
|
|
863
|
-
slot: locked,
|
|
864
|
-
remainingPax: locked.remaining_pax,
|
|
865
|
-
slotChange: buildSlotChange(locked, locked.remaining_pax, source),
|
|
866
|
-
};
|
|
867
|
-
}
|
|
868
|
-
const currentRemaining = locked.remaining_pax ?? 0;
|
|
869
|
-
const nextRemaining = currentRemaining + delta;
|
|
870
|
-
if (nextRemaining < 0) {
|
|
871
|
-
return {
|
|
872
|
-
status: "insufficient_capacity",
|
|
873
|
-
slot: locked,
|
|
874
|
-
remainingPax: currentRemaining,
|
|
875
|
-
};
|
|
876
|
-
}
|
|
877
|
-
let nextStatus = locked.status;
|
|
878
|
-
if (nextRemaining === 0 && locked.status === "open") {
|
|
879
|
-
nextStatus = "sold_out";
|
|
880
|
-
}
|
|
881
|
-
else if (nextRemaining > 0 && locked.status === "sold_out") {
|
|
882
|
-
nextStatus = "open";
|
|
883
|
-
}
|
|
884
|
-
await db
|
|
885
|
-
.update(availabilitySlotsRef)
|
|
886
|
-
.set({
|
|
887
|
-
remainingPax: nextRemaining,
|
|
888
|
-
status: nextStatus,
|
|
889
|
-
updatedAt: new Date(),
|
|
890
|
-
})
|
|
891
|
-
.where(eq(availabilitySlotsRef.id, slotId));
|
|
892
|
-
return {
|
|
893
|
-
status: "ok",
|
|
894
|
-
slot: locked,
|
|
895
|
-
remainingPax: nextRemaining,
|
|
896
|
-
slotChange: buildSlotChange(locked, nextRemaining, source),
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
function parseAllocationResourceLockRow(row) {
|
|
900
|
-
if (typeof row.id !== "string" ||
|
|
901
|
-
typeof row.kind !== "string" ||
|
|
902
|
-
typeof row.capacity !== "number" ||
|
|
903
|
-
typeof row.slot_id !== "string") {
|
|
904
|
-
throw new Error("allocation resource lock query returned an unexpected row shape");
|
|
905
|
-
}
|
|
906
|
-
return {
|
|
907
|
-
id: row.id,
|
|
908
|
-
kind: row.kind,
|
|
909
|
-
capacity: row.capacity,
|
|
910
|
-
slot_id: row.slot_id,
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
/**
|
|
914
|
-
* Exported for unit tests — production callers go through
|
|
915
|
-
* `assertResourceCapacityForAllocations` /
|
|
916
|
-
* `persistTravelDetailsWithCapacityCheck` below.
|
|
917
|
-
*/
|
|
918
|
-
export async function loadResourceCapacityViolations(db, travelerId, allocations) {
|
|
919
|
-
const entries = Object.entries(allocations ?? {}).filter(([kind, resourceId]) => kind && resourceId);
|
|
920
|
-
if (entries.length === 0)
|
|
921
|
-
return [];
|
|
922
|
-
const resourceIds = entries.map(([, resourceId]) => resourceId);
|
|
923
|
-
// Lock the targeted allocation_resources rows so concurrent
|
|
924
|
-
// assignments to the same resource serialise — otherwise two
|
|
925
|
-
// requests can both see one seat free and both write, leaving the
|
|
926
|
-
// resource over capacity. The caller is responsible for invoking
|
|
927
|
-
// this inside a transaction; outside one this lock degrades to a
|
|
928
|
-
// single-statement no-op which is the same race we had before.
|
|
929
|
-
const resources = await db.execute(sql `
|
|
930
|
-
SELECT id, kind, capacity, slot_id
|
|
931
|
-
FROM allocation_resources
|
|
932
|
-
WHERE id = ANY(${sqlTextArray(resourceIds)})
|
|
933
|
-
FOR UPDATE
|
|
934
|
-
`);
|
|
935
|
-
const resourceList = Array.from(resources, parseAllocationResourceLockRow);
|
|
936
|
-
const resourceById = new Map(resourceList.map((row) => [row.id, row]));
|
|
937
|
-
// Entries whose resource exists under the requested kind need a traveler
|
|
938
|
-
// count; the rest (missing resource / kind mismatch) are violations
|
|
939
|
-
// outright and never had a count in the per-resource form either.
|
|
940
|
-
const countedChecks = [];
|
|
941
|
-
for (const [kind, resourceId] of entries) {
|
|
942
|
-
const resource = resourceById.get(resourceId);
|
|
943
|
-
if (resource && resource.kind === kind) {
|
|
944
|
-
countedChecks.push({ kind, resourceId, slotId: resource.slot_id });
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
// ONE grouped count for all checked (kind, resource) pairs. `kind` varies
|
|
948
|
-
// per entry (it is the jsonb key inside `btd.allocations`), so a plain
|
|
949
|
-
// GROUP BY over a static column can't express the check — instead a
|
|
950
|
-
// VALUES join carries each pair's kind + resource + slot and the jsonb
|
|
951
|
-
// lookup joins against it. Replaces the COUNT-per-resource loop this
|
|
952
|
-
// function used to run (N round trips for N allocation entries).
|
|
953
|
-
const assignedByCheck = new Map();
|
|
954
|
-
if (countedChecks.length > 0) {
|
|
955
|
-
const checkTuples = sql.join(countedChecks.map(
|
|
956
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
957
|
-
(check) => sql `(${check.kind}::text, ${check.resourceId}::text, ${check.slotId}::text)`), sql.raw(", "));
|
|
958
|
-
const counts = await db.execute(sql `
|
|
959
|
-
SELECT checks.kind AS kind,
|
|
960
|
-
checks.resource_id AS resource_id,
|
|
961
|
-
COUNT(DISTINCT btd.traveler_id)::int AS count
|
|
962
|
-
FROM (VALUES ${checkTuples}) AS checks(kind, resource_id, slot_id)
|
|
963
|
-
JOIN booking_traveler_travel_details btd
|
|
964
|
-
ON btd.allocations ->> checks.kind = checks.resource_id
|
|
965
|
-
JOIN booking_travelers bt ON bt.id = btd.traveler_id
|
|
966
|
-
JOIN booking_allocations ba
|
|
967
|
-
ON ba.booking_id = bt.booking_id
|
|
968
|
-
AND ba.availability_slot_id = checks.slot_id
|
|
969
|
-
JOIN bookings b ON b.id = bt.booking_id
|
|
970
|
-
WHERE b.status IN ('draft', 'on_hold', 'confirmed', 'in_progress', 'completed')
|
|
971
|
-
AND ba.status IN ('held', 'confirmed', 'fulfilled')
|
|
972
|
-
AND btd.traveler_id <> ${travelerId}
|
|
973
|
-
GROUP BY checks.kind, checks.resource_id
|
|
974
|
-
`);
|
|
975
|
-
for (const row of toRows(counts)) {
|
|
976
|
-
assignedByCheck.set(`${row.kind}\u0000${row.resource_id}`, row.count ?? 0);
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
const violations = [];
|
|
980
|
-
for (const [kind, resourceId] of entries) {
|
|
981
|
-
const resource = resourceById.get(resourceId);
|
|
982
|
-
if (!resource) {
|
|
983
|
-
violations.push({
|
|
984
|
-
slotId: "",
|
|
985
|
-
resourceId,
|
|
986
|
-
kind,
|
|
987
|
-
capacity: 0,
|
|
988
|
-
existingAssigned: 0,
|
|
989
|
-
});
|
|
990
|
-
continue;
|
|
991
|
-
}
|
|
992
|
-
if (resource.kind !== kind) {
|
|
993
|
-
violations.push({
|
|
994
|
-
slotId: resource.slot_id,
|
|
995
|
-
resourceId,
|
|
996
|
-
kind,
|
|
997
|
-
capacity: resource.capacity,
|
|
998
|
-
existingAssigned: 0,
|
|
999
|
-
});
|
|
1000
|
-
continue;
|
|
1001
|
-
}
|
|
1002
|
-
// Pairs with no live assignments produce no group row — that is the
|
|
1003
|
-
// zero count, same as the old per-resource COUNT returning 0.
|
|
1004
|
-
const existingAssigned = assignedByCheck.get(`${kind}\u0000${resourceId}`) ?? 0;
|
|
1005
|
-
if (existingAssigned + 1 > resource.capacity) {
|
|
1006
|
-
violations.push({
|
|
1007
|
-
slotId: resource.slot_id,
|
|
1008
|
-
resourceId,
|
|
1009
|
-
kind,
|
|
1010
|
-
capacity: resource.capacity,
|
|
1011
|
-
existingAssigned,
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
return violations;
|
|
1016
|
-
}
|
|
1017
|
-
async function assertResourceCapacityForAllocations(db, travelerId, allocations) {
|
|
1018
|
-
if (!allocations)
|
|
1019
|
-
return;
|
|
1020
|
-
const violations = await loadResourceCapacityViolations(db, travelerId, allocations);
|
|
1021
|
-
if (violations.length > 0) {
|
|
1022
|
-
throw new BookingServiceError("resource_capacity_exhausted", `Allocation resource over capacity: ${violations
|
|
1023
|
-
.map((v) => `${v.kind}=${v.resourceId} (cap ${v.capacity}, assigned ${v.existingAssigned})`)
|
|
1024
|
-
.join(", ")}`);
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
/**
|
|
1028
|
-
* Lock the targeted resource rows, validate, and write in one
|
|
1029
|
-
* transaction so two concurrent assignments to the last seat in a
|
|
1030
|
-
* resource cannot both pass a stale capacity check. When there are no
|
|
1031
|
-
* allocations to enforce, we skip the wrapping transaction entirely —
|
|
1032
|
-
* avoids the round trip for the common path and keeps the unit-test
|
|
1033
|
-
* mocks (which don't stub `db.transaction`) working unchanged.
|
|
1034
|
-
*/
|
|
1035
|
-
async function persistTravelDetailsWithCapacityCheck(db, travelerId, input, opts) {
|
|
1036
|
-
const allocations = input.allocations;
|
|
1037
|
-
const hasAllocations = allocations != null && Object.values(allocations).some((value) => Boolean(value));
|
|
1038
|
-
if (!hasAllocations) {
|
|
1039
|
-
return opts.pii.upsertTravelerTravelDetails(db, travelerId, input, opts.actorId);
|
|
1040
|
-
}
|
|
1041
|
-
return db.transaction(async (tx) => {
|
|
1042
|
-
await assertResourceCapacityForAllocations(tx, travelerId, allocations);
|
|
1043
|
-
return opts.pii.upsertTravelerTravelDetails(tx, travelerId, input, opts.actorId);
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
async function releaseAllocationCapacity(db, allocation, source = "cancel") {
|
|
1047
|
-
if (!allocation.availabilitySlotId) {
|
|
1048
|
-
return undefined;
|
|
1049
|
-
}
|
|
1050
|
-
if (!allocationStatusConsumesSlotCapacity(allocation.status)) {
|
|
1051
|
-
return undefined;
|
|
1052
|
-
}
|
|
1053
|
-
const result = await adjustSlotCapacity(db, allocation.availabilitySlotId, allocation.quantity, source);
|
|
1054
|
-
return result.status === "ok" ? result.slotChange : undefined;
|
|
1055
|
-
}
|
|
1056
|
-
async function reserveBookingFromTransactionSource(db, source, data, userId, runtime = {}) {
|
|
1057
|
-
const slotChanges = [];
|
|
1058
|
-
try {
|
|
1059
|
-
const result = await db.transaction(async (tx) => {
|
|
1060
|
-
const holdExpiresAt = await computeHoldExpiresAt(tx, data, source.items);
|
|
1061
|
-
const dateRange = deriveBookingDateRange(source.items);
|
|
1062
|
-
const pax = deriveBookingPax(source.participants, source.items);
|
|
1063
|
-
const [booking] = await tx
|
|
1064
|
-
.insert(bookings)
|
|
1065
|
-
.values({
|
|
1066
|
-
bookingNumber: data.bookingNumber,
|
|
1067
|
-
status: "on_hold",
|
|
1068
|
-
personId: source.personId,
|
|
1069
|
-
organizationId: source.organizationId,
|
|
1070
|
-
sourceType: data.sourceType,
|
|
1071
|
-
contactFirstName: data.contactFirstName ?? source.contactFirstName,
|
|
1072
|
-
contactLastName: data.contactLastName ?? source.contactLastName,
|
|
1073
|
-
contactPartyType: data.contactPartyType ?? source.contactPartyType,
|
|
1074
|
-
contactTaxId: data.contactTaxId ?? source.contactTaxId,
|
|
1075
|
-
contactEmail: data.contactEmail ?? source.contactEmail,
|
|
1076
|
-
contactPhone: data.contactPhone ?? source.contactPhone,
|
|
1077
|
-
contactPreferredLanguage: data.contactPreferredLanguage ?? source.contactPreferredLanguage,
|
|
1078
|
-
contactCountry: data.contactCountry ?? source.contactCountry,
|
|
1079
|
-
contactRegion: data.contactRegion ?? source.contactRegion,
|
|
1080
|
-
contactCity: data.contactCity ?? source.contactCity,
|
|
1081
|
-
contactAddressLine1: data.contactAddressLine1 ?? source.contactAddressLine1,
|
|
1082
|
-
contactAddressLine2: data.contactAddressLine2 ?? source.contactAddressLine2,
|
|
1083
|
-
contactPostalCode: data.contactPostalCode ?? source.contactPostalCode,
|
|
1084
|
-
sellCurrency: source.currency,
|
|
1085
|
-
baseCurrency: source.baseCurrency,
|
|
1086
|
-
sellAmountCents: source.totalAmountCents,
|
|
1087
|
-
costAmountCents: source.costAmountCents,
|
|
1088
|
-
startDate: dateRange.startDate,
|
|
1089
|
-
endDate: dateRange.endDate,
|
|
1090
|
-
pax: pax > 0 ? pax : null,
|
|
1091
|
-
internalNotes: data.internalNotes ?? source.notes,
|
|
1092
|
-
holdExpiresAt,
|
|
1093
|
-
})
|
|
1094
|
-
.returning();
|
|
1095
|
-
if (!booking) {
|
|
1096
|
-
throw new BookingServiceError("booking_create_failed");
|
|
1097
|
-
}
|
|
1098
|
-
const participantMap = new Map();
|
|
1099
|
-
const staffParticipantMap = new Map();
|
|
1100
|
-
if (data.includeParticipants) {
|
|
1101
|
-
for (const participant of source.participants) {
|
|
1102
|
-
if (isStaffParticipantType(participant.participantType)) {
|
|
1103
|
-
staffParticipantMap.set(participant.id, participant);
|
|
1104
|
-
continue;
|
|
1105
|
-
}
|
|
1106
|
-
const [createdParticipant] = await tx
|
|
1107
|
-
.insert(bookingTravelers)
|
|
1108
|
-
.values({
|
|
1109
|
-
bookingId: booking.id,
|
|
1110
|
-
personId: participant.personId ?? null,
|
|
1111
|
-
participantType: participant.participantType,
|
|
1112
|
-
travelerCategory: participant.travelerCategory ??
|
|
1113
|
-
null,
|
|
1114
|
-
firstName: participant.firstName,
|
|
1115
|
-
lastName: participant.lastName,
|
|
1116
|
-
email: participant.email ?? null,
|
|
1117
|
-
phone: participant.phone ?? null,
|
|
1118
|
-
preferredLanguage: participant.preferredLanguage ?? null,
|
|
1119
|
-
isPrimary: participant.isPrimary,
|
|
1120
|
-
notes: participant.notes ?? null,
|
|
1121
|
-
})
|
|
1122
|
-
.returning();
|
|
1123
|
-
if (!createdParticipant) {
|
|
1124
|
-
throw new BookingServiceError("participant_create_failed");
|
|
1125
|
-
}
|
|
1126
|
-
participantMap.set(participant.id, createdParticipant.id);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
const bookingItemMap = new Map();
|
|
1130
|
-
for (const item of source.items) {
|
|
1131
|
-
if (item.slotId) {
|
|
1132
|
-
const capacity = await adjustSlotCapacity(tx, item.slotId, -item.quantity, "booking");
|
|
1133
|
-
if (capacity.status === "slot_not_found") {
|
|
1134
|
-
throw new BookingServiceError("slot_not_found");
|
|
1135
|
-
}
|
|
1136
|
-
if (capacity.status === "slot_unavailable") {
|
|
1137
|
-
throw new BookingServiceError("slot_unavailable");
|
|
1138
|
-
}
|
|
1139
|
-
if (capacity.status === "insufficient_capacity") {
|
|
1140
|
-
throw new BookingServiceError("insufficient_capacity");
|
|
1141
|
-
}
|
|
1142
|
-
const slot = capacity.slot;
|
|
1143
|
-
if (item.productId && item.productId !== slot.product_id) {
|
|
1144
|
-
throw new BookingServiceError("slot_product_mismatch");
|
|
1145
|
-
}
|
|
1146
|
-
// Per issue #960, a per-item `optionId` no longer has to match
|
|
1147
|
-
// `slot.option_id` — option-scoped slots accept items for any
|
|
1148
|
-
// option that lives on the same product. `getConvertProductData`
|
|
1149
|
-
// already validated each line's option belongs to the product;
|
|
1150
|
-
// total pax is enforced by `adjustSlotCapacity` above.
|
|
1151
|
-
if (capacity.slotChange)
|
|
1152
|
-
slotChanges.push(capacity.slotChange);
|
|
1153
|
-
}
|
|
1154
|
-
const [bookingItem] = await tx
|
|
1155
|
-
.insert(bookingItems)
|
|
1156
|
-
.values({
|
|
1157
|
-
bookingId: booking.id,
|
|
1158
|
-
title: item.title,
|
|
1159
|
-
description: item.description ?? null,
|
|
1160
|
-
itemType: item.itemType,
|
|
1161
|
-
status: "on_hold",
|
|
1162
|
-
serviceDate: item.serviceDate ?? (item.slotId ? undefined : null),
|
|
1163
|
-
startsAt: item.startsAt ?? null,
|
|
1164
|
-
endsAt: item.endsAt ?? null,
|
|
1165
|
-
quantity: item.quantity,
|
|
1166
|
-
sellCurrency: item.sellCurrency,
|
|
1167
|
-
unitSellAmountCents: item.unitSellAmountCents ?? null,
|
|
1168
|
-
totalSellAmountCents: item.totalSellAmountCents ?? null,
|
|
1169
|
-
costCurrency: item.costCurrency ?? null,
|
|
1170
|
-
unitCostAmountCents: item.unitCostAmountCents ?? null,
|
|
1171
|
-
totalCostAmountCents: item.totalCostAmountCents ?? null,
|
|
1172
|
-
notes: item.notes ?? null,
|
|
1173
|
-
productId: item.productId ?? null,
|
|
1174
|
-
optionId: item.optionId ?? null,
|
|
1175
|
-
optionUnitId: item.unitId ?? null,
|
|
1176
|
-
sourceOfferId: source.offerId,
|
|
1177
|
-
metadata: item.metadata ?? null,
|
|
1178
|
-
})
|
|
1179
|
-
.returning();
|
|
1180
|
-
if (!bookingItem) {
|
|
1181
|
-
throw new BookingServiceError("booking_item_create_failed");
|
|
1182
|
-
}
|
|
1183
|
-
bookingItemMap.set(item.id, bookingItem.id);
|
|
1184
|
-
if (item.slotId) {
|
|
1185
|
-
const [allocation] = await tx
|
|
1186
|
-
.insert(bookingAllocations)
|
|
1187
|
-
.values({
|
|
1188
|
-
bookingId: booking.id,
|
|
1189
|
-
bookingItemId: bookingItem.id,
|
|
1190
|
-
productId: item.productId ?? null,
|
|
1191
|
-
optionId: item.optionId ?? null,
|
|
1192
|
-
optionUnitId: item.unitId ?? null,
|
|
1193
|
-
availabilitySlotId: item.slotId,
|
|
1194
|
-
quantity: item.quantity,
|
|
1195
|
-
allocationType: "unit",
|
|
1196
|
-
status: "held",
|
|
1197
|
-
holdExpiresAt,
|
|
1198
|
-
metadata: item.metadata ?? null,
|
|
1199
|
-
})
|
|
1200
|
-
.returning();
|
|
1201
|
-
if (!allocation) {
|
|
1202
|
-
throw new BookingServiceError("allocation_create_failed");
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
for (const link of source.itemParticipants) {
|
|
1207
|
-
const sourceItemId = getTransactionItemParticipantItemId(link);
|
|
1208
|
-
if (!sourceItemId) {
|
|
1209
|
-
continue;
|
|
1210
|
-
}
|
|
1211
|
-
const bookingItemId = bookingItemMap.get(sourceItemId);
|
|
1212
|
-
const travelerId = participantMap.get(link.travelerId);
|
|
1213
|
-
if (!bookingItemId || !travelerId) {
|
|
1214
|
-
continue;
|
|
1215
|
-
}
|
|
1216
|
-
await tx.insert(bookingItemTravelers).values({
|
|
1217
|
-
bookingItemId,
|
|
1218
|
-
travelerId,
|
|
1219
|
-
role: link.role,
|
|
1220
|
-
isPrimary: link.isPrimary,
|
|
1221
|
-
});
|
|
1222
|
-
}
|
|
1223
|
-
if (staffParticipantMap.size > 0) {
|
|
1224
|
-
const linkedStaffAssignments = [];
|
|
1225
|
-
const linkedStaffParticipantIds = new Set();
|
|
1226
|
-
for (const link of source.itemParticipants) {
|
|
1227
|
-
const staffParticipant = staffParticipantMap.get(link.travelerId);
|
|
1228
|
-
if (!staffParticipant) {
|
|
1229
|
-
continue;
|
|
1230
|
-
}
|
|
1231
|
-
const sourceItemId = getTransactionItemParticipantItemId(link);
|
|
1232
|
-
if (!sourceItemId) {
|
|
1233
|
-
continue;
|
|
1234
|
-
}
|
|
1235
|
-
const bookingItemId = bookingItemMap.get(sourceItemId);
|
|
1236
|
-
if (!bookingItemId) {
|
|
1237
|
-
continue;
|
|
1238
|
-
}
|
|
1239
|
-
linkedStaffParticipantIds.add(staffParticipant.id);
|
|
1240
|
-
linkedStaffAssignments.push({
|
|
1241
|
-
bookingId: booking.id,
|
|
1242
|
-
bookingItemId,
|
|
1243
|
-
personId: staffParticipant.personId ?? null,
|
|
1244
|
-
role: toStaffAssignmentRole(link.role),
|
|
1245
|
-
firstName: staffParticipant.firstName,
|
|
1246
|
-
lastName: staffParticipant.lastName,
|
|
1247
|
-
email: staffParticipant.email ?? null,
|
|
1248
|
-
phone: staffParticipant.phone ?? null,
|
|
1249
|
-
preferredLanguage: staffParticipant.preferredLanguage ?? null,
|
|
1250
|
-
isPrimary: link.isPrimary || staffParticipant.isPrimary,
|
|
1251
|
-
notes: staffParticipant.notes ?? null,
|
|
1252
|
-
metadata: {
|
|
1253
|
-
sourceParticipantId: staffParticipant.id,
|
|
1254
|
-
sourceItemId,
|
|
1255
|
-
sourceRole: link.role,
|
|
1256
|
-
},
|
|
1257
|
-
});
|
|
1258
|
-
}
|
|
1259
|
-
for (const staffParticipant of staffParticipantMap.values()) {
|
|
1260
|
-
if (linkedStaffParticipantIds.has(staffParticipant.id)) {
|
|
1261
|
-
continue;
|
|
1262
|
-
}
|
|
1263
|
-
linkedStaffAssignments.push({
|
|
1264
|
-
bookingId: booking.id,
|
|
1265
|
-
bookingItemId: null,
|
|
1266
|
-
personId: staffParticipant.personId ?? null,
|
|
1267
|
-
role: "service_assignee",
|
|
1268
|
-
firstName: staffParticipant.firstName,
|
|
1269
|
-
lastName: staffParticipant.lastName,
|
|
1270
|
-
email: staffParticipant.email ?? null,
|
|
1271
|
-
phone: staffParticipant.phone ?? null,
|
|
1272
|
-
preferredLanguage: staffParticipant.preferredLanguage ?? null,
|
|
1273
|
-
isPrimary: staffParticipant.isPrimary,
|
|
1274
|
-
notes: staffParticipant.notes ?? null,
|
|
1275
|
-
metadata: {
|
|
1276
|
-
sourceParticipantId: staffParticipant.id,
|
|
1277
|
-
},
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1280
|
-
if (linkedStaffAssignments.length > 0) {
|
|
1281
|
-
await tx.insert(bookingStaffAssignments).values(linkedStaffAssignments);
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
await tx
|
|
1285
|
-
.insert(bookingTransactionDetailsRef)
|
|
1286
|
-
.values({
|
|
1287
|
-
bookingId: booking.id,
|
|
1288
|
-
offerId: source.offerId,
|
|
1289
|
-
orderId: source.orderId,
|
|
1290
|
-
createdAt: new Date(),
|
|
1291
|
-
updatedAt: new Date(),
|
|
1292
|
-
})
|
|
1293
|
-
.onConflictDoUpdate({
|
|
1294
|
-
target: bookingTransactionDetailsRef.bookingId,
|
|
1295
|
-
set: {
|
|
1296
|
-
offerId: source.offerId,
|
|
1297
|
-
orderId: source.orderId,
|
|
1298
|
-
updatedAt: new Date(),
|
|
1299
|
-
},
|
|
1300
|
-
});
|
|
1301
|
-
await tx.insert(bookingActivityLog).values({
|
|
1302
|
-
bookingId: booking.id,
|
|
1303
|
-
actorId: userId ?? "system",
|
|
1304
|
-
activityType: "booking_reserved",
|
|
1305
|
-
description: `Booking ${booking.bookingNumber} reserved from ${source.kind} ${source.sourceId}`,
|
|
1306
|
-
metadata: {
|
|
1307
|
-
sourceKind: source.kind,
|
|
1308
|
-
sourceId: source.sourceId,
|
|
1309
|
-
offerId: source.offerId,
|
|
1310
|
-
orderId: source.orderId,
|
|
1311
|
-
holdExpiresAt: holdExpiresAt.toISOString(),
|
|
1312
|
-
itemCount: source.items.length,
|
|
1313
|
-
},
|
|
1314
|
-
});
|
|
1315
|
-
if (data.note) {
|
|
1316
|
-
await tx.insert(bookingNotes).values({
|
|
1317
|
-
bookingId: booking.id,
|
|
1318
|
-
authorId: userId ?? "system",
|
|
1319
|
-
content: data.note,
|
|
1320
|
-
});
|
|
1321
|
-
}
|
|
1322
|
-
return { status: "ok", booking };
|
|
1323
|
-
});
|
|
1324
|
-
if (result.status === "ok") {
|
|
1325
|
-
await emitSlotChanges(runtime, slotChanges);
|
|
1326
|
-
}
|
|
1327
|
-
return result;
|
|
1328
|
-
}
|
|
1329
|
-
catch (error) {
|
|
1330
|
-
if (error instanceof BookingServiceError) {
|
|
1331
|
-
return { status: error.code };
|
|
1332
|
-
}
|
|
1333
|
-
throw error;
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
async function getBookingTransactionLink(db, bookingId) {
|
|
1337
|
-
const [link] = await db
|
|
1338
|
-
.select()
|
|
1339
|
-
.from(bookingTransactionDetailsRef)
|
|
1340
|
-
.where(eq(bookingTransactionDetailsRef.bookingId, bookingId))
|
|
1341
|
-
.limit(1);
|
|
1342
|
-
return link ?? null;
|
|
1343
|
-
}
|
|
1344
|
-
async function syncTransactionOnBookingConfirmed(db, bookingId) {
|
|
1345
|
-
const link = await getBookingTransactionLink(db, bookingId);
|
|
1346
|
-
if (!link) {
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
const now = new Date();
|
|
1350
|
-
if (link.orderId) {
|
|
1351
|
-
await db
|
|
1352
|
-
.update(ordersRef)
|
|
1353
|
-
.set({
|
|
1354
|
-
status: "confirmed",
|
|
1355
|
-
confirmedAt: now,
|
|
1356
|
-
updatedAt: now,
|
|
1357
|
-
})
|
|
1358
|
-
.where(eq(ordersRef.id, link.orderId));
|
|
1359
|
-
}
|
|
1360
|
-
if (link.offerId) {
|
|
1361
|
-
await db
|
|
1362
|
-
.update(offersRef)
|
|
1363
|
-
.set({
|
|
1364
|
-
status: "converted",
|
|
1365
|
-
acceptedAt: now,
|
|
1366
|
-
convertedAt: now,
|
|
1367
|
-
updatedAt: now,
|
|
1368
|
-
})
|
|
1369
|
-
.where(eq(offersRef.id, link.offerId));
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
async function syncTransactionOnBookingExpired(db, bookingId) {
|
|
1373
|
-
const link = await getBookingTransactionLink(db, bookingId);
|
|
1374
|
-
if (!link?.orderId) {
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
const now = new Date();
|
|
1378
|
-
await db
|
|
1379
|
-
.update(ordersRef)
|
|
1380
|
-
.set({
|
|
1381
|
-
status: "expired",
|
|
1382
|
-
expiresAt: now,
|
|
1383
|
-
updatedAt: now,
|
|
1384
|
-
})
|
|
1385
|
-
.where(eq(ordersRef.id, link.orderId));
|
|
1386
|
-
}
|
|
1387
|
-
async function syncTransactionOnBookingCancelled(db, bookingId) {
|
|
1388
|
-
const link = await getBookingTransactionLink(db, bookingId);
|
|
1389
|
-
if (!link?.orderId) {
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
const now = new Date();
|
|
1393
|
-
await db
|
|
1394
|
-
.update(ordersRef)
|
|
1395
|
-
.set({
|
|
1396
|
-
status: "cancelled",
|
|
1397
|
-
cancelledAt: now,
|
|
1398
|
-
updatedAt: now,
|
|
1399
|
-
})
|
|
1400
|
-
.where(eq(ordersRef.id, link.orderId));
|
|
1401
|
-
}
|
|
1402
|
-
async function syncTransactionOnBookingRedeemed(db, bookingId) {
|
|
1403
|
-
const link = await getBookingTransactionLink(db, bookingId);
|
|
1404
|
-
if (!link?.orderId) {
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
await db
|
|
1408
|
-
.update(ordersRef)
|
|
1409
|
-
.set({
|
|
1410
|
-
status: "fulfilled",
|
|
1411
|
-
updatedAt: new Date(),
|
|
1412
|
-
})
|
|
1413
|
-
.where(eq(ordersRef.id, link.orderId));
|
|
1414
|
-
}
|
|
1415
|
-
async function autoIssueFulfillmentsForBooking(db, bookingId, userId) {
|
|
1416
|
-
const [booking] = await db
|
|
1417
|
-
.select({
|
|
1418
|
-
id: bookings.id,
|
|
1419
|
-
bookingNumber: bookings.bookingNumber,
|
|
1420
|
-
})
|
|
1421
|
-
.from(bookings)
|
|
1422
|
-
.where(eq(bookings.id, bookingId))
|
|
1423
|
-
.limit(1);
|
|
1424
|
-
if (!booking) {
|
|
1425
|
-
return;
|
|
1426
|
-
}
|
|
1427
|
-
const existingFulfillment = await db
|
|
1428
|
-
.select({ id: bookingFulfillments.id })
|
|
1429
|
-
.from(bookingFulfillments)
|
|
1430
|
-
.where(eq(bookingFulfillments.bookingId, bookingId))
|
|
1431
|
-
.limit(1);
|
|
1432
|
-
if (existingFulfillment.length > 0) {
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
const items = await db
|
|
1436
|
-
.select()
|
|
1437
|
-
.from(bookingItems)
|
|
1438
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1439
|
-
.where(and(eq(bookingItems.bookingId, bookingId), sql `${bookingItems.productId} IS NOT NULL`))
|
|
1440
|
-
.orderBy(asc(bookingItems.createdAt));
|
|
1441
|
-
if (items.length === 0) {
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
const productIds = [
|
|
1445
|
-
...new Set(items.map((item) => item.productId).filter((value) => Boolean(value))),
|
|
1446
|
-
];
|
|
1447
|
-
if (productIds.length === 0) {
|
|
1448
|
-
return;
|
|
1449
|
-
}
|
|
1450
|
-
const settings = await db
|
|
1451
|
-
.select()
|
|
1452
|
-
.from(productTicketSettingsRef)
|
|
1453
|
-
.where(inArray(productTicketSettingsRef.productId, productIds));
|
|
1454
|
-
const settingsByProductId = new Map(settings.map((setting) => [setting.productId, setting]));
|
|
1455
|
-
const travelerParticipants = await db
|
|
1456
|
-
.select()
|
|
1457
|
-
.from(bookingTravelers)
|
|
1458
|
-
.where(and(eq(bookingTravelers.bookingId, bookingId), or(eq(bookingTravelers.participantType, "traveler"), eq(bookingTravelers.participantType, "occupant"))))
|
|
1459
|
-
.orderBy(desc(bookingTravelers.isPrimary), asc(bookingTravelers.createdAt));
|
|
1460
|
-
const participantLinks = await db
|
|
1461
|
-
.select()
|
|
1462
|
-
.from(bookingItemTravelers)
|
|
1463
|
-
.where(
|
|
1464
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1465
|
-
sql `${bookingItemTravelers.bookingItemId} IN (
|
|
1466
|
-
SELECT ${bookingItems.id}
|
|
1467
|
-
FROM ${bookingItems}
|
|
1468
|
-
WHERE ${bookingItems.bookingId} = ${bookingId}
|
|
1469
|
-
)`);
|
|
1470
|
-
const participantLinksByItemId = new Map();
|
|
1471
|
-
for (const link of participantLinks) {
|
|
1472
|
-
const links = participantLinksByItemId.get(link.bookingItemId) ?? [];
|
|
1473
|
-
links.push(link);
|
|
1474
|
-
participantLinksByItemId.set(link.bookingItemId, links);
|
|
1475
|
-
}
|
|
1476
|
-
const fulfillmentsToInsert = [];
|
|
1477
|
-
const now = new Date();
|
|
1478
|
-
for (const item of items) {
|
|
1479
|
-
const productId = item.productId;
|
|
1480
|
-
if (!productId) {
|
|
1481
|
-
continue;
|
|
1482
|
-
}
|
|
1483
|
-
const setting = settingsByProductId.get(productId);
|
|
1484
|
-
if (!setting ||
|
|
1485
|
-
setting.fulfillmentMode === "none" ||
|
|
1486
|
-
setting.defaultDeliveryFormat === "none") {
|
|
1487
|
-
continue;
|
|
1488
|
-
}
|
|
1489
|
-
const delivery = mapDeliveryFormatToFulfillment(setting.defaultDeliveryFormat);
|
|
1490
|
-
const payloadBase = {
|
|
1491
|
-
bookingId,
|
|
1492
|
-
bookingNumber: booking.bookingNumber,
|
|
1493
|
-
productId,
|
|
1494
|
-
bookingItemId: item.id,
|
|
1495
|
-
};
|
|
1496
|
-
if (setting.fulfillmentMode === "per_booking") {
|
|
1497
|
-
if (fulfillmentsToInsert.some((row) => row.bookingItemId === item.id || row.bookingItemId === null)) {
|
|
1498
|
-
continue;
|
|
1499
|
-
}
|
|
1500
|
-
fulfillmentsToInsert.push({
|
|
1501
|
-
bookingId,
|
|
1502
|
-
bookingItemId: item.id,
|
|
1503
|
-
travelerId: null,
|
|
1504
|
-
fulfillmentType: delivery.fulfillmentType,
|
|
1505
|
-
deliveryChannel: delivery.deliveryChannel,
|
|
1506
|
-
status: "issued",
|
|
1507
|
-
payload: { ...payloadBase, scope: "booking" },
|
|
1508
|
-
issuedAt: now,
|
|
1509
|
-
});
|
|
1510
|
-
continue;
|
|
1511
|
-
}
|
|
1512
|
-
if (setting.fulfillmentMode === "per_item") {
|
|
1513
|
-
fulfillmentsToInsert.push({
|
|
1514
|
-
bookingId,
|
|
1515
|
-
bookingItemId: item.id,
|
|
1516
|
-
travelerId: null,
|
|
1517
|
-
fulfillmentType: delivery.fulfillmentType,
|
|
1518
|
-
deliveryChannel: delivery.deliveryChannel,
|
|
1519
|
-
status: "issued",
|
|
1520
|
-
payload: { ...payloadBase, scope: "item" },
|
|
1521
|
-
issuedAt: now,
|
|
1522
|
-
});
|
|
1523
|
-
continue;
|
|
1524
|
-
}
|
|
1525
|
-
const linkedParticipants = participantLinksByItemId
|
|
1526
|
-
.get(item.id)
|
|
1527
|
-
?.map((link) => travelerParticipants.find((participant) => participant.id === link.travelerId))
|
|
1528
|
-
.filter((participant) => Boolean(participant)) ?? [];
|
|
1529
|
-
const participantsForItem = linkedParticipants.length > 0 ? linkedParticipants : travelerParticipants;
|
|
1530
|
-
for (const participant of participantsForItem) {
|
|
1531
|
-
fulfillmentsToInsert.push({
|
|
1532
|
-
bookingId,
|
|
1533
|
-
bookingItemId: item.id,
|
|
1534
|
-
travelerId: participant.id,
|
|
1535
|
-
fulfillmentType: delivery.fulfillmentType,
|
|
1536
|
-
deliveryChannel: delivery.deliveryChannel,
|
|
1537
|
-
status: "issued",
|
|
1538
|
-
payload: {
|
|
1539
|
-
...payloadBase,
|
|
1540
|
-
travelerId: participant.id,
|
|
1541
|
-
scope: "participant",
|
|
1542
|
-
},
|
|
1543
|
-
issuedAt: now,
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
if (fulfillmentsToInsert.length === 0) {
|
|
1548
|
-
return;
|
|
1549
|
-
}
|
|
1550
|
-
await db.insert(bookingFulfillments).values(fulfillmentsToInsert);
|
|
1551
|
-
await db.insert(bookingActivityLog).values({
|
|
1552
|
-
bookingId,
|
|
1553
|
-
actorId: userId ?? "system",
|
|
1554
|
-
activityType: "fulfillment_issued",
|
|
1555
|
-
description: `${fulfillmentsToInsert.length} fulfillment artifact(s) issued automatically`,
|
|
1556
|
-
metadata: { count: fulfillmentsToInsert.length },
|
|
1557
|
-
});
|
|
1558
|
-
}
|
|
1559
|
-
/**
|
|
1560
|
-
* Booking statuses that count as "active" for aggregate purposes (matches the
|
|
1561
|
-
* slot-unit-availability counting rules — cancelled and expired drop out).
|
|
1562
|
-
*/
|
|
1563
|
-
const AGGREGATE_ACTIVE_STATUSES = [
|
|
1564
|
-
"draft",
|
|
1565
|
-
"on_hold",
|
|
1566
|
-
"confirmed",
|
|
1567
|
-
"in_progress",
|
|
1568
|
-
"completed",
|
|
1569
|
-
];
|
|
1570
|
-
/**
|
|
1571
|
-
* Normalize `db.execute(sql)` results across drizzle drivers.
|
|
1572
|
-
* `drizzle-orm/postgres-js` returns rows directly (an array); the
|
|
1573
|
-
* `node-postgres` + `neon-serverless` drivers (used by the operator
|
|
1574
|
-
* template against local pg and Neon WS respectively) wrap them in a
|
|
1575
|
-
* `QueryResult<T>` object with `.rows`. Casting straight to
|
|
1576
|
-
* `Array<T>` and indexing produced silent `undefined`s on the wrapped
|
|
1577
|
-
* shape — the symptom was "Booking not found" on freshly-created
|
|
1578
|
-
* bookings whose status-change followup hit a FOR UPDATE SELECT.
|
|
1579
|
-
*/
|
|
1580
|
-
function toRows(result) {
|
|
1581
|
-
if (Array.isArray(result))
|
|
1582
|
-
return result;
|
|
1583
|
-
if (result && typeof result === "object" && "rows" in result) {
|
|
1584
|
-
const rows = result.rows;
|
|
1585
|
-
return Array.isArray(rows) ? rows : [];
|
|
1586
|
-
}
|
|
1587
|
-
return [];
|
|
1588
|
-
}
|
|
1589
|
-
export const bookingsService = {
|
|
1590
|
-
/**
|
|
1591
|
-
* Pre-aggregated dashboard numbers for the admin bookings surface. Replaces
|
|
1592
|
-
* the pattern of fetching a large `listBookings` page and deriving KPIs
|
|
1593
|
-
* client-side — which broke past the page limit and disagreed across apps
|
|
1594
|
-
* on which statuses count.
|
|
1595
|
-
*
|
|
1596
|
-
* All ranges are UTC-based.
|
|
1597
|
-
*/
|
|
1598
|
-
async getBookingAggregates(db, options = {}) {
|
|
1599
|
-
const upcomingLimit = Math.max(0, Math.min(options.upcomingLimit ?? 8, 20));
|
|
1600
|
-
const fromDate = options.from ? new Date(options.from) : undefined;
|
|
1601
|
-
const toDate = options.to ? new Date(options.to) : undefined;
|
|
1602
|
-
const rangeConditions = [];
|
|
1603
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1604
|
-
if (fromDate)
|
|
1605
|
-
rangeConditions.push(sql `${bookings.createdAt} >= ${fromDate.toISOString()}`);
|
|
1606
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1607
|
-
if (toDate)
|
|
1608
|
-
rangeConditions.push(sql `${bookings.createdAt} < ${toDate.toISOString()}`);
|
|
1609
|
-
const rangeWhere = rangeConditions.length ? and(...rangeConditions) : undefined;
|
|
1610
|
-
const todayUtc = new Date();
|
|
1611
|
-
todayUtc.setUTCHours(0, 0, 0, 0);
|
|
1612
|
-
const todayDateString = todayUtc.toISOString().slice(0, 10);
|
|
1613
|
-
const upcomingFilter = and(inArray(bookings.status, [...AGGREGATE_ACTIVE_STATUSES]),
|
|
1614
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1615
|
-
sql `${bookings.startDate} >= ${todayDateString}`);
|
|
1616
|
-
const [[totalRow], [totalPaxRow], statusRows, monthlyCountsRows, monthlyRevenueRows, [upcomingRow], upcomingItems,] = await Promise.all([
|
|
1617
|
-
db.select({ count: sql `count(*)::int` }).from(bookings).where(rangeWhere),
|
|
1618
|
-
db
|
|
1619
|
-
.select({
|
|
1620
|
-
totalPax: sql `coalesce(sum(${bookings.pax}), 0)::bigint`,
|
|
1621
|
-
})
|
|
1622
|
-
.from(bookings)
|
|
1623
|
-
.where(and(...(rangeConditions.length ? rangeConditions : []), inArray(bookings.status, [...AGGREGATE_ACTIVE_STATUSES]))),
|
|
1624
|
-
db
|
|
1625
|
-
.select({
|
|
1626
|
-
status: bookings.status,
|
|
1627
|
-
count: sql `count(*)::int`,
|
|
1628
|
-
})
|
|
1629
|
-
.from(bookings)
|
|
1630
|
-
.where(rangeWhere)
|
|
1631
|
-
.groupBy(bookings.status),
|
|
1632
|
-
db
|
|
1633
|
-
.select({
|
|
1634
|
-
yearMonth: sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`,
|
|
1635
|
-
count: sql `count(*)::int`,
|
|
1636
|
-
})
|
|
1637
|
-
.from(bookings)
|
|
1638
|
-
.where(rangeWhere)
|
|
1639
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1640
|
-
.groupBy(sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`)
|
|
1641
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1642
|
-
.orderBy(sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`),
|
|
1643
|
-
db
|
|
1644
|
-
.select({
|
|
1645
|
-
yearMonth: sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`,
|
|
1646
|
-
currency: bookings.sellCurrency,
|
|
1647
|
-
sellAmountCents: sql `coalesce(sum(${bookings.sellAmountCents}), 0)::bigint`,
|
|
1648
|
-
})
|
|
1649
|
-
.from(bookings)
|
|
1650
|
-
.where(and(...(rangeConditions.length ? rangeConditions : []),
|
|
1651
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1652
|
-
sql `${bookings.sellAmountCents} IS NOT NULL`, inArray(bookings.status, [...AGGREGATE_ACTIVE_STATUSES])))
|
|
1653
|
-
.groupBy(
|
|
1654
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1655
|
-
sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`, bookings.sellCurrency)
|
|
1656
|
-
.orderBy(
|
|
1657
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1658
|
-
sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`, bookings.sellCurrency),
|
|
1659
|
-
db.select({ count: sql `count(*)::int` }).from(bookings).where(upcomingFilter),
|
|
1660
|
-
upcomingLimit === 0
|
|
1661
|
-
? Promise.resolve([])
|
|
1662
|
-
: db
|
|
1663
|
-
.select({
|
|
1664
|
-
id: bookings.id,
|
|
1665
|
-
bookingNumber: bookings.bookingNumber,
|
|
1666
|
-
status: bookings.status,
|
|
1667
|
-
startDate: bookings.startDate,
|
|
1668
|
-
endDate: bookings.endDate,
|
|
1669
|
-
pax: bookings.pax,
|
|
1670
|
-
sellCurrency: bookings.sellCurrency,
|
|
1671
|
-
sellAmountCents: bookings.sellAmountCents,
|
|
1672
|
-
})
|
|
1673
|
-
.from(bookings)
|
|
1674
|
-
.where(upcomingFilter)
|
|
1675
|
-
.orderBy(asc(bookings.startDate), asc(bookings.id))
|
|
1676
|
-
.limit(upcomingLimit),
|
|
1677
|
-
]);
|
|
1678
|
-
const countsByStatusMap = new Map(statusRows.map((row) => [row.status, row.count]));
|
|
1679
|
-
return {
|
|
1680
|
-
total: totalRow?.count ?? 0,
|
|
1681
|
-
totalPax: Number(totalPaxRow?.totalPax ?? 0),
|
|
1682
|
-
countsByStatus: AGGREGATE_ACTIVE_STATUSES.concat(["expired", "cancelled"]).map((status) => ({
|
|
1683
|
-
status,
|
|
1684
|
-
count: countsByStatusMap.get(status) ?? 0,
|
|
1685
|
-
})),
|
|
1686
|
-
monthlyCounts: monthlyCountsRows.map((row) => ({
|
|
1687
|
-
yearMonth: row.yearMonth,
|
|
1688
|
-
count: row.count,
|
|
1689
|
-
})),
|
|
1690
|
-
monthlyRevenue: monthlyRevenueRows.map((row) => ({
|
|
1691
|
-
yearMonth: row.yearMonth,
|
|
1692
|
-
currency: row.currency,
|
|
1693
|
-
sellAmountCents: Number(row.sellAmountCents),
|
|
1694
|
-
})),
|
|
1695
|
-
upcomingDepartures: {
|
|
1696
|
-
count: upcomingRow?.count ?? 0,
|
|
1697
|
-
items: upcomingItems,
|
|
1698
|
-
},
|
|
1699
|
-
};
|
|
1700
|
-
},
|
|
1701
|
-
async listBookings(db, query) {
|
|
1702
|
-
const conditions = [];
|
|
1703
|
-
if (query.status) {
|
|
1704
|
-
conditions.push(eq(bookings.status, query.status));
|
|
1705
|
-
}
|
|
1706
|
-
const excludeStatuses = query.excludeStatuses
|
|
1707
|
-
? Array.isArray(query.excludeStatuses)
|
|
1708
|
-
? query.excludeStatuses
|
|
1709
|
-
: [query.excludeStatuses]
|
|
1710
|
-
: [];
|
|
1711
|
-
if (excludeStatuses.length > 0) {
|
|
1712
|
-
conditions.push(notInArray(bookings.status, excludeStatuses));
|
|
1713
|
-
}
|
|
1714
|
-
if (query.search) {
|
|
1715
|
-
const searchCondition = buildBookingSearchCondition(query.search);
|
|
1716
|
-
if (searchCondition) {
|
|
1717
|
-
conditions.push(searchCondition);
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
if (query.personId) {
|
|
1721
|
-
conditions.push(eq(bookings.personId, query.personId));
|
|
1722
|
-
}
|
|
1723
|
-
if (query.organizationId) {
|
|
1724
|
-
conditions.push(eq(bookings.organizationId, query.organizationId));
|
|
1725
|
-
}
|
|
1726
|
-
if (query.dateFrom) {
|
|
1727
|
-
conditions.push(gte(bookings.startDate, query.dateFrom));
|
|
1728
|
-
}
|
|
1729
|
-
if (query.dateTo) {
|
|
1730
|
-
conditions.push(lte(bookings.startDate, query.dateTo));
|
|
1731
|
-
}
|
|
1732
|
-
if (query.paxMin !== undefined) {
|
|
1733
|
-
conditions.push(gte(bookings.pax, query.paxMin));
|
|
1734
|
-
}
|
|
1735
|
-
if (query.paxMax !== undefined) {
|
|
1736
|
-
conditions.push(lte(bookings.pax, query.paxMax));
|
|
1737
|
-
}
|
|
1738
|
-
if (query.productId ||
|
|
1739
|
-
query.optionId ||
|
|
1740
|
-
query.supplierId ||
|
|
1741
|
-
query.productCategoryId ||
|
|
1742
|
-
query.availabilitySlotId) {
|
|
1743
|
-
const itemConditions = [eq(bookingItems.bookingId, bookings.id)];
|
|
1744
|
-
if (query.productId) {
|
|
1745
|
-
itemConditions.push(eq(bookingItems.productId, query.productId));
|
|
1746
|
-
}
|
|
1747
|
-
if (query.optionId) {
|
|
1748
|
-
itemConditions.push(eq(bookingItems.optionId, query.optionId));
|
|
1749
|
-
}
|
|
1750
|
-
if (query.availabilitySlotId) {
|
|
1751
|
-
itemConditions.push(eq(bookingItems.availabilitySlotId, query.availabilitySlotId));
|
|
1752
|
-
}
|
|
1753
|
-
if (query.supplierId) {
|
|
1754
|
-
itemConditions.push(exists(db
|
|
1755
|
-
.select({ one: sql `1` })
|
|
1756
|
-
.from(productsRef)
|
|
1757
|
-
.where(and(eq(productsRef.id, bookingItems.productId), eq(productsRef.supplierId, query.supplierId)))));
|
|
1758
|
-
}
|
|
1759
|
-
if (query.productCategoryId) {
|
|
1760
|
-
itemConditions.push(exists(db
|
|
1761
|
-
.select({ one: sql `1` })
|
|
1762
|
-
.from(productCategoryProductsRef)
|
|
1763
|
-
.where(and(eq(productCategoryProductsRef.productId, bookingItems.productId), eq(productCategoryProductsRef.categoryId, query.productCategoryId)))));
|
|
1764
|
-
}
|
|
1765
|
-
conditions.push(exists(db
|
|
1766
|
-
.select({ one: sql `1` })
|
|
1767
|
-
.from(bookingItems)
|
|
1768
|
-
.where(and(...itemConditions))));
|
|
1769
|
-
}
|
|
1770
|
-
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
1771
|
-
const sortColumn = (() => {
|
|
1772
|
-
switch (query.sortBy) {
|
|
1773
|
-
case "bookingNumber":
|
|
1774
|
-
return bookings.bookingNumber;
|
|
1775
|
-
case "status":
|
|
1776
|
-
return bookings.status;
|
|
1777
|
-
case "sellAmount":
|
|
1778
|
-
return bookings.sellAmountCents;
|
|
1779
|
-
case "pax":
|
|
1780
|
-
return bookings.pax;
|
|
1781
|
-
case "startDate":
|
|
1782
|
-
return bookings.startDate;
|
|
1783
|
-
case "endDate":
|
|
1784
|
-
return bookings.endDate;
|
|
1785
|
-
default:
|
|
1786
|
-
return bookings.createdAt;
|
|
1787
|
-
}
|
|
1788
|
-
})();
|
|
1789
|
-
const sortFn = query.sortDir === "asc" ? asc : desc;
|
|
1790
|
-
const [rows, countResult] = await Promise.all([
|
|
1791
|
-
db
|
|
1792
|
-
.select()
|
|
1793
|
-
.from(bookings)
|
|
1794
|
-
.where(where)
|
|
1795
|
-
.limit(query.limit)
|
|
1796
|
-
.offset(query.offset)
|
|
1797
|
-
.orderBy(sortFn(sortColumn), desc(bookings.createdAt)),
|
|
1798
|
-
db.select({ count: sql `count(*)::int` }).from(bookings).where(where),
|
|
1799
|
-
]);
|
|
1800
|
-
const bookingIds = rows.map((row) => row.id);
|
|
1801
|
-
const items = await listBookingItemsForSummaries(db, bookingIds);
|
|
1802
|
-
const ranges = new Map();
|
|
1803
|
-
const itemSummariesByBooking = new Map();
|
|
1804
|
-
for (const item of items) {
|
|
1805
|
-
const current = ranges.get(item.bookingId) ?? { startsAt: null, endsAt: null };
|
|
1806
|
-
if (item.startsAt && (!current.startsAt || item.startsAt < current.startsAt)) {
|
|
1807
|
-
current.startsAt = item.startsAt;
|
|
1808
|
-
}
|
|
1809
|
-
if (item.endsAt && (!current.endsAt || item.endsAt > current.endsAt)) {
|
|
1810
|
-
current.endsAt = item.endsAt;
|
|
1811
|
-
}
|
|
1812
|
-
ranges.set(item.bookingId, current);
|
|
1813
|
-
const list = itemSummariesByBooking.get(item.bookingId) ?? [];
|
|
1814
|
-
list.push({
|
|
1815
|
-
id: item.id,
|
|
1816
|
-
title: item.title,
|
|
1817
|
-
itemType: item.itemType,
|
|
1818
|
-
productId: item.productId,
|
|
1819
|
-
productName: item.productName,
|
|
1820
|
-
startsAt: item.startsAt?.toISOString() ?? null,
|
|
1821
|
-
endsAt: item.endsAt?.toISOString() ?? null,
|
|
1822
|
-
});
|
|
1823
|
-
itemSummariesByBooking.set(item.bookingId, list);
|
|
1824
|
-
}
|
|
1825
|
-
return {
|
|
1826
|
-
data: rows.map((row) => {
|
|
1827
|
-
const range = ranges.get(row.id);
|
|
1828
|
-
return {
|
|
1829
|
-
...row,
|
|
1830
|
-
startsAt: range?.startsAt?.toISOString() ?? null,
|
|
1831
|
-
endsAt: range?.endsAt?.toISOString() ?? null,
|
|
1832
|
-
items: itemSummariesByBooking.get(row.id) ?? [],
|
|
1833
|
-
};
|
|
1834
|
-
}),
|
|
1835
|
-
total: countResult[0]?.count ?? 0,
|
|
1836
|
-
limit: query.limit,
|
|
1837
|
-
offset: query.offset,
|
|
1838
|
-
};
|
|
1839
|
-
},
|
|
1840
|
-
async convertProductToBooking(db, data, productData, userId) {
|
|
1841
|
-
const { product, option, slot, dayServices, units } = productData;
|
|
1842
|
-
// Slot dates win over product dates so scheduled/recurring products don't
|
|
1843
|
-
// land with null dates. endsAt is a timestamp; fall back to the slot's
|
|
1844
|
-
// dateLocal when the slot has no explicit end timestamp.
|
|
1845
|
-
const startDate = slot?.dateLocal ?? product.startDate;
|
|
1846
|
-
const endDate = slot
|
|
1847
|
-
? slot.endsAt
|
|
1848
|
-
? slot.endsAt.toISOString().slice(0, 10)
|
|
1849
|
-
: slot.dateLocal
|
|
1850
|
-
: product.endDate;
|
|
1851
|
-
// Caller-supplied `sellAmountCentsOverride` lets the catalog booking-
|
|
1852
|
-
// engine pass the promotion-discounted base through to the booking
|
|
1853
|
-
// row so the customer is charged the post-discount amount, not the
|
|
1854
|
-
// product's list price. Per docs/architecture/promotions-architecture.md §7.1.
|
|
1855
|
-
const confirmedSellAmountCents = data.confirmedSellAmountCents ?? null;
|
|
1856
|
-
const catalogSellAmountCents = data.catalogSellAmountCents ?? product.sellAmountCents;
|
|
1857
|
-
const effectiveSellAmountCents = confirmedSellAmountCents != null
|
|
1858
|
-
? confirmedSellAmountCents
|
|
1859
|
-
: data.sellAmountCentsOverride != null
|
|
1860
|
-
? data.sellAmountCentsOverride
|
|
1861
|
-
: product.sellAmountCents;
|
|
1862
|
-
const priceOverrideReason = data.priceOverrideReason?.trim() ?? null;
|
|
1863
|
-
const isManualPriceOverride = confirmedSellAmountCents != null && confirmedSellAmountCents !== catalogSellAmountCents;
|
|
1864
|
-
const priceOverride = isManualPriceOverride
|
|
1865
|
-
? {
|
|
1866
|
-
isManual: true,
|
|
1867
|
-
originalAmountCents: catalogSellAmountCents,
|
|
1868
|
-
overriddenAmountCents: confirmedSellAmountCents,
|
|
1869
|
-
currency: product.sellCurrency,
|
|
1870
|
-
reason: priceOverrideReason ?? "Manual price override",
|
|
1871
|
-
overriddenBy: userId ?? "system",
|
|
1872
|
-
overriddenAt: new Date().toISOString(),
|
|
1873
|
-
}
|
|
1874
|
-
: null;
|
|
1875
|
-
const selectedUnits = data.itemLines && data.itemLines.length > 0 ? units : option === null ? [] : units;
|
|
1876
|
-
const unitById = new Map(selectedUnits.map((unit) => [unit.id, unit]));
|
|
1877
|
-
const requestedItemLines = data.itemLines
|
|
1878
|
-
?.map((line) => {
|
|
1879
|
-
const unit = unitById.get(line.optionUnitId);
|
|
1880
|
-
if (!unit)
|
|
1881
|
-
return null;
|
|
1882
|
-
if (line.optionId && line.optionId !== unit.optionId)
|
|
1883
|
-
return null;
|
|
1884
|
-
if (data.optionId && data.optionId !== unit.optionId)
|
|
1885
|
-
return null;
|
|
1886
|
-
return { line, unit };
|
|
1887
|
-
})
|
|
1888
|
-
.filter((entry) => entry !== null) ?? [];
|
|
1889
|
-
if (data.itemLines && requestedItemLines.length !== data.itemLines.length) {
|
|
1890
|
-
return null;
|
|
1891
|
-
}
|
|
1892
|
-
const initialStatus = data.initialStatus ?? "draft";
|
|
1893
|
-
const bookingPax = Object.hasOwn(data, "pax") ? (data.pax ?? null) : product.pax;
|
|
1894
|
-
// Map the booking lifecycle status onto the booking-item lifecycle.
|
|
1895
|
-
// Items don't have an `awaiting_payment` state — when the booking is
|
|
1896
|
-
// committed (confirmed / awaiting payment / in progress) the items
|
|
1897
|
-
// are sold, so they land in `confirmed`. Holds, cancellations,
|
|
1898
|
-
// expirations, and completions cascade their analog. Draft falls
|
|
1899
|
-
// through as draft.
|
|
1900
|
-
const initialItemStatus = initialStatus === "on_hold"
|
|
1901
|
-
? "on_hold"
|
|
1902
|
-
: initialStatus === "confirmed" ||
|
|
1903
|
-
initialStatus === "in_progress" ||
|
|
1904
|
-
initialStatus === "awaiting_payment"
|
|
1905
|
-
? "confirmed"
|
|
1906
|
-
: initialStatus === "cancelled"
|
|
1907
|
-
? "cancelled"
|
|
1908
|
-
: initialStatus === "expired"
|
|
1909
|
-
? "expired"
|
|
1910
|
-
: initialStatus === "completed"
|
|
1911
|
-
? "fulfilled"
|
|
1912
|
-
: "draft";
|
|
1913
|
-
const now = new Date();
|
|
1914
|
-
const [booking] = await db
|
|
1915
|
-
.insert(bookings)
|
|
1916
|
-
.values({
|
|
1917
|
-
bookingNumber: data.bookingNumber,
|
|
1918
|
-
status: initialStatus,
|
|
1919
|
-
// Mirror the lifecycle timestamps that overrideBookingStatus
|
|
1920
|
-
// stamps when the status transition happens after-the-fact, so
|
|
1921
|
-
// a booking that lands in `confirmed` straight from create is
|
|
1922
|
-
// indistinguishable downstream from one that was flipped via
|
|
1923
|
-
// the verb endpoint.
|
|
1924
|
-
confirmedAt: initialStatus === "confirmed" ? now : null,
|
|
1925
|
-
personId: data.personId ?? null,
|
|
1926
|
-
organizationId: data.organizationId ?? null,
|
|
1927
|
-
// Billing-contact snapshot — captured at create time so the
|
|
1928
|
-
// booking detail page renders the right payer even if the
|
|
1929
|
-
// CRM person/org record changes (or is deleted) later.
|
|
1930
|
-
contactFirstName: data.contactFirstName ?? null,
|
|
1931
|
-
contactLastName: data.contactLastName ?? null,
|
|
1932
|
-
contactPartyType: data.contactPartyType ?? null,
|
|
1933
|
-
contactTaxId: data.contactTaxId ?? null,
|
|
1934
|
-
contactEmail: data.contactEmail ?? null,
|
|
1935
|
-
contactPhone: data.contactPhone ?? null,
|
|
1936
|
-
contactPreferredLanguage: data.contactPreferredLanguage ?? null,
|
|
1937
|
-
contactCountry: data.contactCountry ?? null,
|
|
1938
|
-
contactRegion: data.contactRegion ?? null,
|
|
1939
|
-
contactCity: data.contactCity ?? null,
|
|
1940
|
-
contactAddressLine1: data.contactAddressLine1 ?? null,
|
|
1941
|
-
contactAddressLine2: data.contactAddressLine2 ?? null,
|
|
1942
|
-
contactPostalCode: data.contactPostalCode ?? null,
|
|
1943
|
-
sellCurrency: product.sellCurrency,
|
|
1944
|
-
sellAmountCents: effectiveSellAmountCents,
|
|
1945
|
-
priceOverride,
|
|
1946
|
-
costAmountCents: product.costAmountCents,
|
|
1947
|
-
marginPercent: product.marginPercent,
|
|
1948
|
-
startDate,
|
|
1949
|
-
endDate,
|
|
1950
|
-
pax: bookingPax,
|
|
1951
|
-
internalNotes: data.internalNotes ?? null,
|
|
1952
|
-
})
|
|
1953
|
-
.returning();
|
|
1954
|
-
if (!booking) {
|
|
1955
|
-
return null;
|
|
1956
|
-
}
|
|
1957
|
-
if (dayServices.length > 0) {
|
|
1958
|
-
await db.insert(bookingSupplierStatuses).values(dayServices.map((service) => ({
|
|
1959
|
-
bookingId: booking.id,
|
|
1960
|
-
supplierServiceId: service.supplierServiceId,
|
|
1961
|
-
serviceName: service.name,
|
|
1962
|
-
status: "pending",
|
|
1963
|
-
costCurrency: service.costCurrency,
|
|
1964
|
-
costAmountCents: service.costAmountCents,
|
|
1965
|
-
})));
|
|
1966
|
-
}
|
|
1967
|
-
const unitsToSeed = selectedUnits.filter((unit) => unit.isRequired).length > 0
|
|
1968
|
-
? selectedUnits.filter((unit) => unit.isRequired)
|
|
1969
|
-
: selectedUnits.length === 1
|
|
1970
|
-
? selectedUnits
|
|
1971
|
-
: [];
|
|
1972
|
-
// Slot-derived columns + catalog snapshot. `availabilitySlotId`
|
|
1973
|
-
// and `departureLabelSnapshot` carry the departure forward so the
|
|
1974
|
-
// booking detail page can show "Dates" without a JOIN — and the
|
|
1975
|
-
// snapshot survives even if the slot row is later deleted.
|
|
1976
|
-
// `metadata.availabilitySlotId` is kept for backwards compatibility
|
|
1977
|
-
// with the older write path; new readers should prefer the column.
|
|
1978
|
-
const slotFields = slot
|
|
1979
|
-
? {
|
|
1980
|
-
serviceDate: slot.dateLocal,
|
|
1981
|
-
startsAt: slot.startsAt,
|
|
1982
|
-
endsAt: slot.endsAt,
|
|
1983
|
-
availabilitySlotId: slot.id,
|
|
1984
|
-
departureLabelSnapshot: formatDepartureLabel(slot.startsAt, slot.timezone),
|
|
1985
|
-
metadata: { availabilitySlotId: slot.id },
|
|
1986
|
-
}
|
|
1987
|
-
: {
|
|
1988
|
-
availabilitySlotId: null,
|
|
1989
|
-
departureLabelSnapshot: null,
|
|
1990
|
-
metadata: null,
|
|
1991
|
-
};
|
|
1992
|
-
// Catalog snapshot shared across every item row (product + option
|
|
1993
|
-
// names are the same for the whole booking; the per-row unit name
|
|
1994
|
-
// is filled in below).
|
|
1995
|
-
const productOptionSnapshot = {
|
|
1996
|
-
productNameSnapshot: product.name,
|
|
1997
|
-
optionNameSnapshot: option?.name ?? null,
|
|
1998
|
-
};
|
|
1999
|
-
// Stamp the wire-format `clientLineKey` into the inserted item's
|
|
2000
|
-
// metadata so the booking-create orchestrator can find the row
|
|
2001
|
-
// afterward and write `booking_item_travelers` linkages. The
|
|
2002
|
-
// existing slot metadata (when present) is preserved.
|
|
2003
|
-
const itemLineMetadata = (clientLineKey) => {
|
|
2004
|
-
const slotMetadata = (slotFields.metadata ?? {});
|
|
2005
|
-
return clientLineKey
|
|
2006
|
-
? { ...slotMetadata, bookingCreateLineKey: clientLineKey }
|
|
2007
|
-
: slotFields.metadata;
|
|
2008
|
-
};
|
|
2009
|
-
// Seeded line-item totals must match the booking's `sellAmountCents`
|
|
2010
|
-
// so checkout / payment / invoicing don't see a list-price item beneath
|
|
2011
|
-
// a discounted booking header.
|
|
2012
|
-
const itemRows = requestedItemLines.length > 0
|
|
2013
|
-
? requestedItemLines.map(({ line, unit }) => {
|
|
2014
|
-
const totalSellAmountCents = line.totalSellAmountCents ??
|
|
2015
|
-
(line.unitSellAmountCents != null ? line.unitSellAmountCents * line.quantity : null);
|
|
2016
|
-
return {
|
|
2017
|
-
bookingId: booking.id,
|
|
2018
|
-
title: line.title?.trim() || unit.name,
|
|
2019
|
-
description: line.description ?? unit.description,
|
|
2020
|
-
itemType: "unit",
|
|
2021
|
-
status: initialItemStatus,
|
|
2022
|
-
quantity: line.quantity,
|
|
2023
|
-
sellCurrency: product.sellCurrency,
|
|
2024
|
-
unitSellAmountCents: line.unitSellAmountCents ?? null,
|
|
2025
|
-
totalSellAmountCents,
|
|
2026
|
-
costCurrency: null,
|
|
2027
|
-
unitCostAmountCents: null,
|
|
2028
|
-
totalCostAmountCents: null,
|
|
2029
|
-
productId: product.id,
|
|
2030
|
-
optionId: unit.optionId,
|
|
2031
|
-
optionUnitId: unit.id,
|
|
2032
|
-
...productOptionSnapshot,
|
|
2033
|
-
unitNameSnapshot: unit.name,
|
|
2034
|
-
...slotFields,
|
|
2035
|
-
metadata: itemLineMetadata(line.clientLineKey),
|
|
2036
|
-
};
|
|
2037
|
-
})
|
|
2038
|
-
: unitsToSeed.length > 0
|
|
2039
|
-
? unitsToSeed.map((unit, index) => {
|
|
2040
|
-
const quantity = unit.unitType === "person" && bookingPax
|
|
2041
|
-
? bookingPax
|
|
2042
|
-
: unit.minQuantity && unit.minQuantity > 0
|
|
2043
|
-
? unit.minQuantity
|
|
2044
|
-
: 1;
|
|
2045
|
-
const singleSeedItem = unitsToSeed.length === 1 && index === 0;
|
|
2046
|
-
return {
|
|
2047
|
-
bookingId: booking.id,
|
|
2048
|
-
title: unit.name,
|
|
2049
|
-
description: unit.description,
|
|
2050
|
-
itemType: "unit",
|
|
2051
|
-
status: initialItemStatus,
|
|
2052
|
-
quantity,
|
|
2053
|
-
sellCurrency: product.sellCurrency,
|
|
2054
|
-
unitSellAmountCents: singleSeedItem &&
|
|
2055
|
-
effectiveSellAmountCents !== null &&
|
|
2056
|
-
effectiveSellAmountCents !== undefined
|
|
2057
|
-
? Math.floor(effectiveSellAmountCents / quantity)
|
|
2058
|
-
: null,
|
|
2059
|
-
totalSellAmountCents: singleSeedItem ? (effectiveSellAmountCents ?? null) : null,
|
|
2060
|
-
costCurrency: singleSeedItem ? product.sellCurrency : null,
|
|
2061
|
-
unitCostAmountCents: singleSeedItem &&
|
|
2062
|
-
product.costAmountCents !== null &&
|
|
2063
|
-
product.costAmountCents !== undefined
|
|
2064
|
-
? Math.floor(product.costAmountCents / quantity)
|
|
2065
|
-
: null,
|
|
2066
|
-
totalCostAmountCents: singleSeedItem ? (product.costAmountCents ?? null) : null,
|
|
2067
|
-
productId: product.id,
|
|
2068
|
-
optionId: option?.id ?? null,
|
|
2069
|
-
optionUnitId: unit.id,
|
|
2070
|
-
...productOptionSnapshot,
|
|
2071
|
-
unitNameSnapshot: unit.name,
|
|
2072
|
-
...slotFields,
|
|
2073
|
-
};
|
|
2074
|
-
})
|
|
2075
|
-
: [
|
|
2076
|
-
{
|
|
2077
|
-
bookingId: booking.id,
|
|
2078
|
-
title: option?.name ?? product.name,
|
|
2079
|
-
description: product.description,
|
|
2080
|
-
itemType: "unit",
|
|
2081
|
-
status: initialItemStatus,
|
|
2082
|
-
quantity: 1,
|
|
2083
|
-
sellCurrency: product.sellCurrency,
|
|
2084
|
-
unitSellAmountCents: effectiveSellAmountCents ?? null,
|
|
2085
|
-
totalSellAmountCents: effectiveSellAmountCents ?? null,
|
|
2086
|
-
costCurrency: product.sellCurrency,
|
|
2087
|
-
unitCostAmountCents: product.costAmountCents ?? null,
|
|
2088
|
-
totalCostAmountCents: product.costAmountCents ?? null,
|
|
2089
|
-
productId: product.id,
|
|
2090
|
-
optionId: option?.id ?? null,
|
|
2091
|
-
optionUnitId: null,
|
|
2092
|
-
...productOptionSnapshot,
|
|
2093
|
-
unitNameSnapshot: null,
|
|
2094
|
-
...slotFields,
|
|
2095
|
-
},
|
|
2096
|
-
];
|
|
2097
|
-
const insertedItems = await db.insert(bookingItems).values(itemRows).returning();
|
|
2098
|
-
const allocationRows = insertedItems
|
|
2099
|
-
.filter((item) => item.availabilitySlotId)
|
|
2100
|
-
.map((item) => ({
|
|
2101
|
-
bookingId: booking.id,
|
|
2102
|
-
bookingItemId: item.id,
|
|
2103
|
-
productId: item.productId ?? null,
|
|
2104
|
-
optionId: item.optionId ?? null,
|
|
2105
|
-
optionUnitId: item.optionUnitId ?? null,
|
|
2106
|
-
pricingCategoryId: item.pricingCategoryId ?? null,
|
|
2107
|
-
availabilitySlotId: item.availabilitySlotId,
|
|
2108
|
-
quantity: item.quantity,
|
|
2109
|
-
allocationType: "unit",
|
|
2110
|
-
status: allocationStatusForBookingItemStatus(item.status),
|
|
2111
|
-
holdExpiresAt: null,
|
|
2112
|
-
metadata: item.metadata ?? null,
|
|
2113
|
-
}));
|
|
2114
|
-
if (allocationRows.length > 0) {
|
|
2115
|
-
for (const allocation of allocationRows) {
|
|
2116
|
-
if (!allocation.availabilitySlotId ||
|
|
2117
|
-
!allocationStatusConsumesSlotCapacity(allocation.status)) {
|
|
2118
|
-
continue;
|
|
2119
|
-
}
|
|
2120
|
-
const capacity = await adjustSlotCapacity(db, allocation.availabilitySlotId, -allocation.quantity, "booking");
|
|
2121
|
-
if (capacity.status === "slot_not_found") {
|
|
2122
|
-
throw new BookingServiceError("slot_not_found");
|
|
2123
|
-
}
|
|
2124
|
-
if (capacity.status === "slot_unavailable") {
|
|
2125
|
-
throw new BookingServiceError("slot_unavailable");
|
|
2126
|
-
}
|
|
2127
|
-
if (capacity.status === "insufficient_capacity") {
|
|
2128
|
-
throw new BookingServiceError("insufficient_capacity");
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
await db.insert(bookingAllocations).values(allocationRows);
|
|
2132
|
-
}
|
|
2133
|
-
await db
|
|
2134
|
-
.insert(bookingProductDetailsRef)
|
|
2135
|
-
.values({
|
|
2136
|
-
bookingId: booking.id,
|
|
2137
|
-
productId: product.id,
|
|
2138
|
-
optionId: option?.id ?? null,
|
|
2139
|
-
})
|
|
2140
|
-
.onConflictDoUpdate({
|
|
2141
|
-
target: bookingProductDetailsRef.bookingId,
|
|
2142
|
-
set: {
|
|
2143
|
-
productId: product.id,
|
|
2144
|
-
optionId: option?.id ?? null,
|
|
2145
|
-
updatedAt: new Date(),
|
|
2146
|
-
},
|
|
2147
|
-
});
|
|
2148
|
-
if (insertedItems.length > 0) {
|
|
2149
|
-
await db.insert(bookingItemProductDetailsRef).values(insertedItems.map((item) => ({
|
|
2150
|
-
bookingItemId: item.id,
|
|
2151
|
-
productId: item.productId ?? null,
|
|
2152
|
-
optionId: item.optionId ?? null,
|
|
2153
|
-
unitId: item.optionUnitId ?? null,
|
|
2154
|
-
supplierServiceId: null,
|
|
2155
|
-
})));
|
|
2156
|
-
}
|
|
2157
|
-
await db.insert(bookingActivityLog).values({
|
|
2158
|
-
bookingId: booking.id,
|
|
2159
|
-
actorId: userId ?? "system",
|
|
2160
|
-
activityType: "booking_converted",
|
|
2161
|
-
description: `Booking converted from product "${product.name}"`,
|
|
2162
|
-
metadata: {
|
|
2163
|
-
productId: product.id,
|
|
2164
|
-
productName: product.name,
|
|
2165
|
-
optionId: option?.id ?? null,
|
|
2166
|
-
slotId: slot?.id ?? null,
|
|
2167
|
-
},
|
|
2168
|
-
});
|
|
2169
|
-
if (priceOverride) {
|
|
2170
|
-
await db.insert(bookingActivityLog).values({
|
|
2171
|
-
bookingId: booking.id,
|
|
2172
|
-
actorId: userId ?? "system",
|
|
2173
|
-
activityType: "system_action",
|
|
2174
|
-
description: "Booking sell total manually overridden during create",
|
|
2175
|
-
metadata: { kind: "booking_price_overridden", ...priceOverride },
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
2178
|
-
return booking;
|
|
2179
|
-
},
|
|
2180
|
-
async getBookingById(db, id) {
|
|
2181
|
-
const [row] = await db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
|
|
2182
|
-
return row ?? null;
|
|
2183
|
-
},
|
|
2184
|
-
async createBookingFromProduct(db, data, userId) {
|
|
2185
|
-
const productData = await getConvertProductData(db, data);
|
|
2186
|
-
if (!productData) {
|
|
2187
|
-
return null;
|
|
2188
|
-
}
|
|
2189
|
-
return this.convertProductToBooking(db, data, productData, userId);
|
|
2190
|
-
},
|
|
2191
|
-
listAllocations(db, bookingId) {
|
|
2192
|
-
return db
|
|
2193
|
-
.select()
|
|
2194
|
-
.from(bookingAllocations)
|
|
2195
|
-
.where(eq(bookingAllocations.bookingId, bookingId))
|
|
2196
|
-
.orderBy(asc(bookingAllocations.createdAt));
|
|
2197
|
-
},
|
|
2198
|
-
async reserveBookingFromOffer(db, offerId, data, userId, runtime = {}) {
|
|
2199
|
-
const [offer] = await db.select().from(offersRef).where(eq(offersRef.id, offerId)).limit(1);
|
|
2200
|
-
if (!offer) {
|
|
2201
|
-
return { status: "not_found" };
|
|
2202
|
-
}
|
|
2203
|
-
const [participants, items, itemParticipants, staffAssignments] = await Promise.all([
|
|
2204
|
-
db
|
|
2205
|
-
.select()
|
|
2206
|
-
.from(offerParticipantsRef)
|
|
2207
|
-
.where(eq(offerParticipantsRef.offerId, offerId))
|
|
2208
|
-
.orderBy(asc(offerParticipantsRef.createdAt)),
|
|
2209
|
-
db
|
|
2210
|
-
.select()
|
|
2211
|
-
.from(offerItemsRef)
|
|
2212
|
-
.where(eq(offerItemsRef.offerId, offerId))
|
|
2213
|
-
.orderBy(asc(offerItemsRef.createdAt)),
|
|
2214
|
-
db
|
|
2215
|
-
.select()
|
|
2216
|
-
.from(offerItemParticipantsRef)
|
|
2217
|
-
.where(
|
|
2218
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
2219
|
-
sql `${offerItemParticipantsRef.offerItemId} IN (
|
|
2220
|
-
SELECT ${offerItemsRef.id}
|
|
2221
|
-
FROM ${offerItemsRef}
|
|
2222
|
-
WHERE ${offerItemsRef.offerId} = ${offerId}
|
|
2223
|
-
)`)
|
|
2224
|
-
.orderBy(asc(offerItemParticipantsRef.createdAt)),
|
|
2225
|
-
db
|
|
2226
|
-
.select()
|
|
2227
|
-
.from(offerStaffAssignmentsRef)
|
|
2228
|
-
.where(eq(offerStaffAssignmentsRef.offerId, offerId))
|
|
2229
|
-
.orderBy(asc(offerStaffAssignmentsRef.createdAt)),
|
|
2230
|
-
]);
|
|
2231
|
-
const reservationParticipants = [...participants];
|
|
2232
|
-
const reservationItemParticipants = itemParticipants.map((link) => ({
|
|
2233
|
-
travelerId: link.travelerId,
|
|
2234
|
-
role: link.role,
|
|
2235
|
-
isPrimary: link.isPrimary,
|
|
2236
|
-
offerItemId: link.offerItemId,
|
|
2237
|
-
}));
|
|
2238
|
-
for (const assignment of staffAssignments) {
|
|
2239
|
-
const participant = toStaffReservationParticipant(assignment, "offer");
|
|
2240
|
-
reservationParticipants.push(participant);
|
|
2241
|
-
if (assignment.offerItemId) {
|
|
2242
|
-
reservationItemParticipants.push({
|
|
2243
|
-
travelerId: participant.id,
|
|
2244
|
-
role: assignment.role,
|
|
2245
|
-
isPrimary: assignment.isPrimary,
|
|
2246
|
-
offerItemId: assignment.offerItemId,
|
|
2247
|
-
});
|
|
2248
|
-
}
|
|
2249
|
-
}
|
|
2250
|
-
return reserveBookingFromTransactionSource(db, {
|
|
2251
|
-
kind: "offer",
|
|
2252
|
-
sourceId: offerId,
|
|
2253
|
-
offerId: offer.id,
|
|
2254
|
-
orderId: null,
|
|
2255
|
-
personId: offer.personId ?? null,
|
|
2256
|
-
organizationId: offer.organizationId ?? null,
|
|
2257
|
-
contactFirstName: offer.contactFirstName ?? null,
|
|
2258
|
-
contactLastName: offer.contactLastName ?? null,
|
|
2259
|
-
contactPartyType: offer.contactPartyType ?? null,
|
|
2260
|
-
contactTaxId: offer.contactTaxId ?? null,
|
|
2261
|
-
contactEmail: offer.contactEmail ?? null,
|
|
2262
|
-
contactPhone: offer.contactPhone ?? null,
|
|
2263
|
-
contactPreferredLanguage: offer.contactPreferredLanguage ?? null,
|
|
2264
|
-
contactCountry: offer.contactCountry ?? null,
|
|
2265
|
-
contactRegion: offer.contactRegion ?? null,
|
|
2266
|
-
contactCity: offer.contactCity ?? null,
|
|
2267
|
-
contactAddressLine1: offer.contactAddressLine1 ?? null,
|
|
2268
|
-
contactAddressLine2: offer.contactAddressLine2 ?? null,
|
|
2269
|
-
contactPostalCode: offer.contactPostalCode ?? null,
|
|
2270
|
-
currency: offer.currency,
|
|
2271
|
-
baseCurrency: offer.baseCurrency ?? null,
|
|
2272
|
-
totalAmountCents: offer.totalAmountCents ?? null,
|
|
2273
|
-
costAmountCents: offer.costAmountCents ?? null,
|
|
2274
|
-
notes: offer.notes ?? null,
|
|
2275
|
-
participants: reservationParticipants,
|
|
2276
|
-
items,
|
|
2277
|
-
itemParticipants: reservationItemParticipants,
|
|
2278
|
-
}, data, userId, runtime);
|
|
2279
|
-
},
|
|
2280
|
-
async reserveBookingFromOrder(db, orderId, data, userId, runtime = {}) {
|
|
2281
|
-
const [order] = await db.select().from(ordersRef).where(eq(ordersRef.id, orderId)).limit(1);
|
|
2282
|
-
if (!order) {
|
|
2283
|
-
return { status: "not_found" };
|
|
2284
|
-
}
|
|
2285
|
-
const [participants, items, itemParticipants, staffAssignments] = await Promise.all([
|
|
2286
|
-
db
|
|
2287
|
-
.select()
|
|
2288
|
-
.from(orderParticipantsRef)
|
|
2289
|
-
.where(eq(orderParticipantsRef.orderId, orderId))
|
|
2290
|
-
.orderBy(asc(orderParticipantsRef.createdAt)),
|
|
2291
|
-
db
|
|
2292
|
-
.select()
|
|
2293
|
-
.from(orderItemsRef)
|
|
2294
|
-
.where(eq(orderItemsRef.orderId, orderId))
|
|
2295
|
-
.orderBy(asc(orderItemsRef.createdAt)),
|
|
2296
|
-
db
|
|
2297
|
-
.select()
|
|
2298
|
-
.from(orderItemParticipantsRef)
|
|
2299
|
-
.where(
|
|
2300
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
2301
|
-
sql `${orderItemParticipantsRef.orderItemId} IN (
|
|
2302
|
-
SELECT ${orderItemsRef.id}
|
|
2303
|
-
FROM ${orderItemsRef}
|
|
2304
|
-
WHERE ${orderItemsRef.orderId} = ${orderId}
|
|
2305
|
-
)`)
|
|
2306
|
-
.orderBy(asc(orderItemParticipantsRef.createdAt)),
|
|
2307
|
-
db
|
|
2308
|
-
.select()
|
|
2309
|
-
.from(orderStaffAssignmentsRef)
|
|
2310
|
-
.where(eq(orderStaffAssignmentsRef.orderId, orderId))
|
|
2311
|
-
.orderBy(asc(orderStaffAssignmentsRef.createdAt)),
|
|
2312
|
-
]);
|
|
2313
|
-
const reservationParticipants = [...participants];
|
|
2314
|
-
const reservationItemParticipants = itemParticipants.map((link) => ({
|
|
2315
|
-
travelerId: link.travelerId,
|
|
2316
|
-
role: link.role,
|
|
2317
|
-
isPrimary: link.isPrimary,
|
|
2318
|
-
orderItemId: link.orderItemId,
|
|
2319
|
-
}));
|
|
2320
|
-
for (const assignment of staffAssignments) {
|
|
2321
|
-
const participant = toStaffReservationParticipant(assignment, "order");
|
|
2322
|
-
reservationParticipants.push(participant);
|
|
2323
|
-
if (assignment.orderItemId) {
|
|
2324
|
-
reservationItemParticipants.push({
|
|
2325
|
-
travelerId: participant.id,
|
|
2326
|
-
role: assignment.role,
|
|
2327
|
-
isPrimary: assignment.isPrimary,
|
|
2328
|
-
orderItemId: assignment.orderItemId,
|
|
2329
|
-
});
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
|
-
return reserveBookingFromTransactionSource(db, {
|
|
2333
|
-
kind: "order",
|
|
2334
|
-
sourceId: orderId,
|
|
2335
|
-
offerId: order.offerId ?? null,
|
|
2336
|
-
orderId: order.id,
|
|
2337
|
-
personId: order.personId ?? null,
|
|
2338
|
-
organizationId: order.organizationId ?? null,
|
|
2339
|
-
contactFirstName: order.contactFirstName ?? null,
|
|
2340
|
-
contactLastName: order.contactLastName ?? null,
|
|
2341
|
-
contactPartyType: order.contactPartyType ?? null,
|
|
2342
|
-
contactTaxId: order.contactTaxId ?? null,
|
|
2343
|
-
contactEmail: order.contactEmail ?? null,
|
|
2344
|
-
contactPhone: order.contactPhone ?? null,
|
|
2345
|
-
contactPreferredLanguage: order.contactPreferredLanguage ?? null,
|
|
2346
|
-
contactCountry: order.contactCountry ?? null,
|
|
2347
|
-
contactRegion: order.contactRegion ?? null,
|
|
2348
|
-
contactCity: order.contactCity ?? null,
|
|
2349
|
-
contactAddressLine1: order.contactAddressLine1 ?? null,
|
|
2350
|
-
contactAddressLine2: order.contactAddressLine2 ?? null,
|
|
2351
|
-
contactPostalCode: order.contactPostalCode ?? null,
|
|
2352
|
-
currency: order.currency,
|
|
2353
|
-
baseCurrency: order.baseCurrency ?? null,
|
|
2354
|
-
totalAmountCents: order.totalAmountCents ?? null,
|
|
2355
|
-
costAmountCents: order.costAmountCents ?? null,
|
|
2356
|
-
notes: order.notes ?? null,
|
|
2357
|
-
participants: reservationParticipants,
|
|
2358
|
-
items,
|
|
2359
|
-
itemParticipants: reservationItemParticipants,
|
|
2360
|
-
}, data, userId, runtime);
|
|
2361
|
-
},
|
|
2362
|
-
async reserveBooking(db, data, userId, runtime = {}) {
|
|
2363
|
-
const slotChanges = [];
|
|
2364
|
-
try {
|
|
2365
|
-
// Everything that doesn't need the slot locks runs BEFORE the
|
|
2366
|
-
// transaction opens so the `FOR UPDATE` critical section stays as
|
|
2367
|
-
// short as possible (T7, perf RFC): hold-policy resolution and the
|
|
2368
|
-
// catalog name snapshots are plain reads of catalog/policy data
|
|
2369
|
-
// that the slot lock doesn't protect anyway (a product rename
|
|
2370
|
-
// could land mid-transaction either way).
|
|
2371
|
-
const holdExpiresAt = await computeHoldExpiresAt(db, data, data.items);
|
|
2372
|
-
// Unlocked pre-read of slot -> product/option so items that omit
|
|
2373
|
-
// productId/optionId can still resolve their catalog snapshot
|
|
2374
|
-
// pre-transaction. `product_id`/`option_id` are immutable on a
|
|
2375
|
-
// slot, so this read cannot diverge from the locked read inside
|
|
2376
|
-
// the transaction. Missing table (catalog-less deployment) or a
|
|
2377
|
-
// missing slot just degrades the snapshot to nulls — the locked
|
|
2378
|
-
// read inside the transaction stays authoritative for existence
|
|
2379
|
-
// and mismatch checks.
|
|
2380
|
-
const slotIds = [...new Set(data.items.map((item) => item.availabilitySlotId))];
|
|
2381
|
-
const slotInfo = new Map();
|
|
2382
|
-
try {
|
|
2383
|
-
const slotRows = await db
|
|
2384
|
-
.select({
|
|
2385
|
-
id: availabilitySlotsRef.id,
|
|
2386
|
-
productId: availabilitySlotsRef.productId,
|
|
2387
|
-
optionId: availabilitySlotsRef.optionId,
|
|
2388
|
-
})
|
|
2389
|
-
.from(availabilitySlotsRef)
|
|
2390
|
-
.where(inArray(availabilitySlotsRef.id, slotIds));
|
|
2391
|
-
for (const row of slotRows) {
|
|
2392
|
-
slotInfo.set(row.id, { productId: row.productId, optionId: row.optionId });
|
|
2393
|
-
}
|
|
2394
|
-
}
|
|
2395
|
-
catch (error) {
|
|
2396
|
-
if (!isUndefinedTableError(error))
|
|
2397
|
-
throw error;
|
|
2398
|
-
}
|
|
2399
|
-
const itemSnapshots = await Promise.all(data.items.map((item) => {
|
|
2400
|
-
const preSlot = slotInfo.get(item.availabilitySlotId);
|
|
2401
|
-
return resolveBookingItemSnapshot(db, {
|
|
2402
|
-
productId: item.productId ?? preSlot?.productId ?? null,
|
|
2403
|
-
optionId: item.optionId ?? preSlot?.optionId ?? null,
|
|
2404
|
-
optionUnitId: item.optionUnitId ?? null,
|
|
2405
|
-
availabilitySlotId: item.availabilitySlotId,
|
|
2406
|
-
});
|
|
2407
|
-
}));
|
|
2408
|
-
const result = await db.transaction(async (tx) => {
|
|
2409
|
-
const [booking] = await tx
|
|
2410
|
-
.insert(bookings)
|
|
2411
|
-
.values({
|
|
2412
|
-
bookingNumber: data.bookingNumber,
|
|
2413
|
-
status: "on_hold",
|
|
2414
|
-
personId: data.personId ?? null,
|
|
2415
|
-
organizationId: data.organizationId ?? null,
|
|
2416
|
-
sourceType: data.sourceType,
|
|
2417
|
-
externalBookingRef: data.externalBookingRef ?? null,
|
|
2418
|
-
communicationLanguage: data.communicationLanguage ?? null,
|
|
2419
|
-
contactFirstName: data.contactFirstName ?? null,
|
|
2420
|
-
contactLastName: data.contactLastName ?? null,
|
|
2421
|
-
contactPartyType: data.contactPartyType ?? null,
|
|
2422
|
-
contactTaxId: data.contactTaxId ?? null,
|
|
2423
|
-
contactEmail: data.contactEmail ?? null,
|
|
2424
|
-
contactPhone: data.contactPhone ?? null,
|
|
2425
|
-
contactPreferredLanguage: data.contactPreferredLanguage ?? null,
|
|
2426
|
-
contactCountry: data.contactCountry ?? null,
|
|
2427
|
-
contactRegion: data.contactRegion ?? null,
|
|
2428
|
-
contactCity: data.contactCity ?? null,
|
|
2429
|
-
contactAddressLine1: data.contactAddressLine1 ?? null,
|
|
2430
|
-
contactAddressLine2: data.contactAddressLine2 ?? null,
|
|
2431
|
-
contactPostalCode: data.contactPostalCode ?? null,
|
|
2432
|
-
sellCurrency: data.sellCurrency,
|
|
2433
|
-
baseCurrency: data.baseCurrency ?? null,
|
|
2434
|
-
sellAmountCents: data.sellAmountCents ?? null,
|
|
2435
|
-
baseSellAmountCents: data.baseSellAmountCents ?? null,
|
|
2436
|
-
costAmountCents: data.costAmountCents ?? null,
|
|
2437
|
-
baseCostAmountCents: data.baseCostAmountCents ?? null,
|
|
2438
|
-
marginPercent: data.marginPercent ?? null,
|
|
2439
|
-
startDate: data.startDate ?? null,
|
|
2440
|
-
endDate: data.endDate ?? null,
|
|
2441
|
-
pax: data.pax ?? null,
|
|
2442
|
-
internalNotes: data.internalNotes ?? null,
|
|
2443
|
-
holdExpiresAt,
|
|
2444
|
-
})
|
|
2445
|
-
.returning();
|
|
2446
|
-
if (!booking) {
|
|
2447
|
-
throw new BookingServiceError("booking_create_failed");
|
|
2448
|
-
}
|
|
2449
|
-
// The locked critical section: per item, only the slot lock +
|
|
2450
|
-
// capacity adjustment runs serially (correctness — that's the
|
|
2451
|
-
// row the `FOR UPDATE` protects). Row payloads are accumulated
|
|
2452
|
-
// and written with ONE batched insert per table afterwards,
|
|
2453
|
-
// instead of two inserts per item while holding the locks.
|
|
2454
|
-
const itemRows = [];
|
|
2455
|
-
const allocationRows = [];
|
|
2456
|
-
for (const [index, item] of data.items.entries()) {
|
|
2457
|
-
const capacity = await adjustSlotCapacity(tx, item.availabilitySlotId, -item.quantity, "booking");
|
|
2458
|
-
if (capacity.status === "slot_not_found") {
|
|
2459
|
-
throw new BookingServiceError("slot_not_found");
|
|
2460
|
-
}
|
|
2461
|
-
if (capacity.status === "slot_unavailable") {
|
|
2462
|
-
throw new BookingServiceError("slot_unavailable");
|
|
2463
|
-
}
|
|
2464
|
-
if (capacity.status === "insufficient_capacity") {
|
|
2465
|
-
throw new BookingServiceError("insufficient_capacity");
|
|
2466
|
-
}
|
|
2467
|
-
const slot = capacity.slot;
|
|
2468
|
-
if (item.productId && item.productId !== slot.product_id) {
|
|
2469
|
-
throw new BookingServiceError("slot_product_mismatch");
|
|
2470
|
-
}
|
|
2471
|
-
if (item.optionId && item.optionId !== slot.option_id) {
|
|
2472
|
-
throw new BookingServiceError("slot_option_mismatch");
|
|
2473
|
-
}
|
|
2474
|
-
if (capacity.slotChange)
|
|
2475
|
-
slotChanges.push(capacity.slotChange);
|
|
2476
|
-
const productId = item.productId ?? slot.product_id;
|
|
2477
|
-
const optionId = item.optionId ?? slot.option_id;
|
|
2478
|
-
const optionUnitId = item.optionUnitId ?? null;
|
|
2479
|
-
const snapshot = itemSnapshots[index];
|
|
2480
|
-
// Ids are generated app-side (same `newId` the column default
|
|
2481
|
-
// uses) so allocations can reference their item without
|
|
2482
|
-
// depending on RETURNING order of the batched insert.
|
|
2483
|
-
const bookingItemId = newId("booking_items");
|
|
2484
|
-
itemRows.push({
|
|
2485
|
-
id: bookingItemId,
|
|
2486
|
-
bookingId: booking.id,
|
|
2487
|
-
title: item.title,
|
|
2488
|
-
description: item.description ?? null,
|
|
2489
|
-
itemType: item.itemType,
|
|
2490
|
-
status: "on_hold",
|
|
2491
|
-
serviceDate: slot.date_local,
|
|
2492
|
-
startsAt: slot.starts_at,
|
|
2493
|
-
endsAt: slot.ends_at,
|
|
2494
|
-
quantity: item.quantity,
|
|
2495
|
-
sellCurrency: item.sellCurrency ?? booking.sellCurrency,
|
|
2496
|
-
unitSellAmountCents: item.unitSellAmountCents ?? null,
|
|
2497
|
-
totalSellAmountCents: item.totalSellAmountCents ?? null,
|
|
2498
|
-
costCurrency: item.costCurrency ?? null,
|
|
2499
|
-
unitCostAmountCents: item.unitCostAmountCents ?? null,
|
|
2500
|
-
totalCostAmountCents: item.totalCostAmountCents ?? null,
|
|
2501
|
-
notes: item.notes ?? null,
|
|
2502
|
-
productId,
|
|
2503
|
-
optionId,
|
|
2504
|
-
optionUnitId,
|
|
2505
|
-
pricingCategoryId: item.pricingCategoryId ?? null,
|
|
2506
|
-
availabilitySlotId: item.availabilitySlotId,
|
|
2507
|
-
productNameSnapshot: item.productNameSnapshot ?? snapshot?.productName ?? null,
|
|
2508
|
-
optionNameSnapshot: item.optionNameSnapshot ?? snapshot?.optionName ?? null,
|
|
2509
|
-
unitNameSnapshot: item.unitNameSnapshot ?? snapshot?.unitName ?? null,
|
|
2510
|
-
departureLabelSnapshot: item.departureLabelSnapshot ?? snapshot?.departureLabel ?? null,
|
|
2511
|
-
sourceSnapshotId: item.sourceSnapshotId ?? null,
|
|
2512
|
-
sourceOfferId: item.sourceOfferId ?? null,
|
|
2513
|
-
metadata: item.metadata ?? null,
|
|
2514
|
-
});
|
|
2515
|
-
allocationRows.push({
|
|
2516
|
-
bookingId: booking.id,
|
|
2517
|
-
bookingItemId,
|
|
2518
|
-
productId,
|
|
2519
|
-
optionId,
|
|
2520
|
-
optionUnitId,
|
|
2521
|
-
pricingCategoryId: item.pricingCategoryId ?? null,
|
|
2522
|
-
availabilitySlotId: item.availabilitySlotId,
|
|
2523
|
-
quantity: item.quantity,
|
|
2524
|
-
allocationType: item.allocationType,
|
|
2525
|
-
status: "held",
|
|
2526
|
-
holdExpiresAt,
|
|
2527
|
-
metadata: item.metadata ?? null,
|
|
2528
|
-
});
|
|
2529
|
-
}
|
|
2530
|
-
const insertedItems = await tx
|
|
2531
|
-
.insert(bookingItems)
|
|
2532
|
-
.values(itemRows)
|
|
2533
|
-
.returning({ id: bookingItems.id });
|
|
2534
|
-
if (insertedItems.length !== itemRows.length) {
|
|
2535
|
-
throw new BookingServiceError("booking_item_create_failed");
|
|
2536
|
-
}
|
|
2537
|
-
const insertedAllocations = await tx
|
|
2538
|
-
.insert(bookingAllocations)
|
|
2539
|
-
.values(allocationRows)
|
|
2540
|
-
.returning({ id: bookingAllocations.id });
|
|
2541
|
-
if (insertedAllocations.length !== allocationRows.length) {
|
|
2542
|
-
throw new BookingServiceError("allocation_create_failed");
|
|
2543
|
-
}
|
|
2544
|
-
await tx.insert(bookingActivityLog).values({
|
|
2545
|
-
bookingId: booking.id,
|
|
2546
|
-
actorId: userId ?? "system",
|
|
2547
|
-
activityType: "booking_reserved",
|
|
2548
|
-
description: `Booking ${booking.bookingNumber} reserved and placed on hold`,
|
|
2549
|
-
metadata: { holdExpiresAt: holdExpiresAt.toISOString(), itemCount: data.items.length },
|
|
2550
|
-
});
|
|
2551
|
-
return { status: "ok", booking };
|
|
2552
|
-
});
|
|
2553
|
-
if (result.status === "ok") {
|
|
2554
|
-
await emitSlotChanges(runtime, slotChanges);
|
|
2555
|
-
}
|
|
2556
|
-
return result;
|
|
2557
|
-
}
|
|
2558
|
-
catch (error) {
|
|
2559
|
-
if (error instanceof BookingServiceError) {
|
|
2560
|
-
return { status: error.code };
|
|
2561
|
-
}
|
|
2562
|
-
throw error;
|
|
2563
|
-
}
|
|
2564
|
-
},
|
|
2565
|
-
async createBooking(db, data, userId) {
|
|
2566
|
-
return db.transaction(async (tx) => {
|
|
2567
|
-
const status = data.status ?? "draft";
|
|
2568
|
-
const [row] = await tx
|
|
2569
|
-
.insert(bookings)
|
|
2570
|
-
.values({
|
|
2571
|
-
...data,
|
|
2572
|
-
status,
|
|
2573
|
-
contactFirstName: data.contactFirstName ?? null,
|
|
2574
|
-
contactLastName: data.contactLastName ?? null,
|
|
2575
|
-
contactPartyType: data.contactPartyType ?? null,
|
|
2576
|
-
contactTaxId: data.contactTaxId ?? null,
|
|
2577
|
-
contactEmail: data.contactEmail ?? null,
|
|
2578
|
-
contactPhone: data.contactPhone ?? null,
|
|
2579
|
-
contactPreferredLanguage: data.contactPreferredLanguage ?? null,
|
|
2580
|
-
contactCountry: data.contactCountry ?? null,
|
|
2581
|
-
contactRegion: data.contactRegion ?? null,
|
|
2582
|
-
contactCity: data.contactCity ?? null,
|
|
2583
|
-
contactAddressLine1: data.contactAddressLine1 ?? null,
|
|
2584
|
-
contactAddressLine2: data.contactAddressLine2 ?? null,
|
|
2585
|
-
contactPostalCode: data.contactPostalCode ?? null,
|
|
2586
|
-
holdExpiresAt: toTimestamp(data.holdExpiresAt),
|
|
2587
|
-
confirmedAt: confirmedAtForStatus(status, toTimestamp(data.confirmedAt)),
|
|
2588
|
-
expiredAt: toTimestamp(data.expiredAt),
|
|
2589
|
-
cancelledAt: toTimestamp(data.cancelledAt),
|
|
2590
|
-
completedAt: toTimestamp(data.completedAt),
|
|
2591
|
-
redeemedAt: toTimestamp(data.redeemedAt),
|
|
2592
|
-
})
|
|
2593
|
-
.returning();
|
|
2594
|
-
if (!row) {
|
|
2595
|
-
return null;
|
|
2596
|
-
}
|
|
2597
|
-
await tx.insert(bookingActivityLog).values({
|
|
2598
|
-
bookingId: row.id,
|
|
2599
|
-
actorId: userId ?? "system",
|
|
2600
|
-
activityType: "booking_created",
|
|
2601
|
-
description: `Booking ${data.bookingNumber} created`,
|
|
2602
|
-
});
|
|
2603
|
-
return row;
|
|
2604
|
-
});
|
|
2605
|
-
},
|
|
2606
|
-
async updateBooking(db, id, data) {
|
|
2607
|
-
const normalizedData = normalizeBookingBillingPartyUpdate(data);
|
|
2608
|
-
return db.transaction(async (tx) => {
|
|
2609
|
-
const rows = await tx.execute(sql `SELECT status
|
|
2610
|
-
FROM ${bookings}
|
|
2611
|
-
WHERE ${bookings.id} = ${id}
|
|
2612
|
-
FOR UPDATE`);
|
|
2613
|
-
const existing = toRows(rows)[0];
|
|
2614
|
-
if (!existing)
|
|
2615
|
-
return null;
|
|
2616
|
-
const [row] = await tx
|
|
2617
|
-
.update(bookings)
|
|
2618
|
-
.set({
|
|
2619
|
-
...normalizedData,
|
|
2620
|
-
contactFirstName: data.contactFirstName === undefined ? undefined : (data.contactFirstName ?? null),
|
|
2621
|
-
contactLastName: data.contactLastName === undefined ? undefined : (data.contactLastName ?? null),
|
|
2622
|
-
contactPartyType: data.contactPartyType === undefined ? undefined : (data.contactPartyType ?? null),
|
|
2623
|
-
contactTaxId: data.contactTaxId === undefined ? undefined : (data.contactTaxId ?? null),
|
|
2624
|
-
contactEmail: data.contactEmail === undefined ? undefined : (data.contactEmail ?? null),
|
|
2625
|
-
contactPhone: data.contactPhone === undefined ? undefined : (data.contactPhone ?? null),
|
|
2626
|
-
contactPreferredLanguage: data.contactPreferredLanguage === undefined
|
|
2627
|
-
? undefined
|
|
2628
|
-
: (data.contactPreferredLanguage ?? null),
|
|
2629
|
-
contactCountry: data.contactCountry === undefined ? undefined : (data.contactCountry ?? null),
|
|
2630
|
-
contactRegion: data.contactRegion === undefined ? undefined : (data.contactRegion ?? null),
|
|
2631
|
-
contactCity: data.contactCity === undefined ? undefined : (data.contactCity ?? null),
|
|
2632
|
-
contactAddressLine1: data.contactAddressLine1 === undefined ? undefined : (data.contactAddressLine1 ?? null),
|
|
2633
|
-
contactAddressLine2: data.contactAddressLine2 === undefined ? undefined : (data.contactAddressLine2 ?? null),
|
|
2634
|
-
contactPostalCode: data.contactPostalCode === undefined ? undefined : (data.contactPostalCode ?? null),
|
|
2635
|
-
holdExpiresAt: data.holdExpiresAt === undefined ? undefined : toTimestamp(data.holdExpiresAt),
|
|
2636
|
-
confirmedAt: confirmedAtForBookingUpdate(existing.status, data),
|
|
2637
|
-
expiredAt: data.expiredAt === undefined ? undefined : toTimestamp(data.expiredAt),
|
|
2638
|
-
cancelledAt: data.cancelledAt === undefined ? undefined : toTimestamp(data.cancelledAt),
|
|
2639
|
-
completedAt: data.completedAt === undefined ? undefined : toTimestamp(data.completedAt),
|
|
2640
|
-
redeemedAt: data.redeemedAt === undefined ? undefined : toTimestamp(data.redeemedAt),
|
|
2641
|
-
updatedAt: new Date(),
|
|
2642
|
-
})
|
|
2643
|
-
.where(eq(bookings.id, id))
|
|
2644
|
-
.returning();
|
|
2645
|
-
return row ?? null;
|
|
2646
|
-
});
|
|
2647
|
-
},
|
|
2648
|
-
async deleteBooking(db, id) {
|
|
2649
|
-
const [row] = await db
|
|
2650
|
-
.delete(bookings)
|
|
2651
|
-
.where(eq(bookings.id, id))
|
|
2652
|
-
.returning({ id: bookings.id });
|
|
2653
|
-
return row ?? null;
|
|
2654
|
-
},
|
|
2655
|
-
async confirmBooking(db, id, data, userId, runtime = {}) {
|
|
2656
|
-
try {
|
|
2657
|
-
const result = await db.transaction(async (tx) => {
|
|
2658
|
-
const rows = await tx.execute(sql `SELECT id, booking_number, status, hold_expires_at
|
|
2659
|
-
FROM ${bookings}
|
|
2660
|
-
WHERE ${bookings.id} = ${id}
|
|
2661
|
-
FOR UPDATE`);
|
|
2662
|
-
const booking = toRows(rows)[0];
|
|
2663
|
-
if (!booking) {
|
|
2664
|
-
throw new BookingServiceError("not_found");
|
|
2665
|
-
}
|
|
2666
|
-
if (!canTransitionBooking(booking.status, "confirmed")) {
|
|
2667
|
-
throw new BookingServiceError("invalid_transition");
|
|
2668
|
-
}
|
|
2669
|
-
// Accept both the staff-brokered "on_hold" and the customer
|
|
2670
|
-
// checkout flow's "awaiting_payment". Other statuses (draft,
|
|
2671
|
-
// already-confirmed, expired, cancelled) reject — the state
|
|
2672
|
-
// machine catches the rest, but we explicitly forbid the
|
|
2673
|
-
// states that would skip a step in the lifecycle.
|
|
2674
|
-
if (booking.status !== "on_hold" && booking.status !== "awaiting_payment") {
|
|
2675
|
-
throw new BookingServiceError("invalid_transition");
|
|
2676
|
-
}
|
|
2677
|
-
if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
|
|
2678
|
-
throw new BookingServiceError("hold_expired");
|
|
2679
|
-
}
|
|
2680
|
-
const patch = transitionBooking(booking.status, "confirmed");
|
|
2681
|
-
await tx
|
|
2682
|
-
.update(bookingAllocations)
|
|
2683
|
-
.set({
|
|
2684
|
-
status: "confirmed",
|
|
2685
|
-
confirmedAt: new Date(),
|
|
2686
|
-
updatedAt: new Date(),
|
|
2687
|
-
})
|
|
2688
|
-
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
|
|
2689
|
-
await tx
|
|
2690
|
-
.update(bookingItems)
|
|
2691
|
-
.set({ status: "confirmed", updatedAt: new Date() })
|
|
2692
|
-
.where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
|
|
2693
|
-
const [row] = await tx
|
|
2694
|
-
.update(bookings)
|
|
2695
|
-
.set({
|
|
2696
|
-
...patch,
|
|
2697
|
-
holdExpiresAt: null,
|
|
2698
|
-
updatedAt: new Date(),
|
|
2699
|
-
})
|
|
2700
|
-
.where(eq(bookings.id, id))
|
|
2701
|
-
.returning();
|
|
2702
|
-
await syncTransactionOnBookingConfirmed(tx, id);
|
|
2703
|
-
await autoIssueFulfillmentsForBooking(tx, id, userId);
|
|
2704
|
-
await tx.insert(bookingActivityLog).values({
|
|
2705
|
-
bookingId: id,
|
|
2706
|
-
actorId: userId ?? "system",
|
|
2707
|
-
activityType: "booking_confirmed",
|
|
2708
|
-
description: `Booking ${booking.booking_number} confirmed`,
|
|
2709
|
-
});
|
|
2710
|
-
if (data.note) {
|
|
2711
|
-
await tx.insert(bookingNotes).values({
|
|
2712
|
-
bookingId: id,
|
|
2713
|
-
authorId: userId ?? "system",
|
|
2714
|
-
content: data.note,
|
|
2715
|
-
});
|
|
2716
|
-
}
|
|
2717
|
-
await appendBookingStatusMutationLedger(tx, runtime, {
|
|
2718
|
-
actionName: "booking.status.confirm",
|
|
2719
|
-
routeOrToolName: "bookings.confirm",
|
|
2720
|
-
capabilityId: BOOKING_STATUS_CAPABILITIES.confirm.id,
|
|
2721
|
-
bookingId: id,
|
|
2722
|
-
fromStatus: booking.status,
|
|
2723
|
-
toStatus: "confirmed",
|
|
2724
|
-
});
|
|
2725
|
-
return { status: "ok", booking: row ?? null };
|
|
2726
|
-
});
|
|
2727
|
-
// Emit AFTER the transaction commits so subscribers can't observe a
|
|
2728
|
-
// confirmed state that might still roll back. `emit` is fire-and-forget
|
|
2729
|
-
// per the EventBus contract — subscriber errors are logged, not rethrown.
|
|
2730
|
-
if (result.status === "ok" && result.booking) {
|
|
2731
|
-
await runtime.eventBus?.emit("booking.confirmed", {
|
|
2732
|
-
bookingId: result.booking.id,
|
|
2733
|
-
bookingNumber: result.booking.bookingNumber,
|
|
2734
|
-
actorId: userId ?? null,
|
|
2735
|
-
suppressNotifications: data.suppressNotifications === true,
|
|
2736
|
-
}, { category: "domain", source: "service" });
|
|
2737
|
-
}
|
|
2738
|
-
return result;
|
|
2739
|
-
}
|
|
2740
|
-
catch (error) {
|
|
2741
|
-
if (error instanceof BookingServiceError) {
|
|
2742
|
-
return { status: error.code };
|
|
2743
|
-
}
|
|
2744
|
-
throw error;
|
|
2745
|
-
}
|
|
2746
|
-
},
|
|
2747
|
-
async recoverExpiredPaidBooking(db, id, data = {}, userId, runtime = {}) {
|
|
2748
|
-
const slotChanges = [];
|
|
2749
|
-
try {
|
|
2750
|
-
const result = await db.transaction(async (tx) => {
|
|
2751
|
-
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
2752
|
-
FROM ${bookings}
|
|
2753
|
-
WHERE ${bookings.id} = ${id}
|
|
2754
|
-
FOR UPDATE`);
|
|
2755
|
-
const booking = toRows(rows)[0];
|
|
2756
|
-
if (!booking) {
|
|
2757
|
-
throw new BookingServiceError("not_found");
|
|
2758
|
-
}
|
|
2759
|
-
if (booking.status !== "awaiting_payment" && booking.status !== "expired") {
|
|
2760
|
-
throw new BookingServiceError("invalid_transition");
|
|
2761
|
-
}
|
|
2762
|
-
const allocations = await tx
|
|
2763
|
-
.select()
|
|
2764
|
-
.from(bookingAllocations)
|
|
2765
|
-
.where(eq(bookingAllocations.bookingId, id));
|
|
2766
|
-
for (const allocation of allocations) {
|
|
2767
|
-
if (allocation.status === "confirmed") {
|
|
2768
|
-
continue;
|
|
2769
|
-
}
|
|
2770
|
-
if (allocation.status !== "held" && allocation.status !== "expired") {
|
|
2771
|
-
throw new BookingServiceError("invalid_transition");
|
|
2772
|
-
}
|
|
2773
|
-
if (!allocation.availabilitySlotId || allocation.status === "held") {
|
|
2774
|
-
continue;
|
|
2775
|
-
}
|
|
2776
|
-
const capacity = await adjustSlotCapacity(tx, allocation.availabilitySlotId, -allocation.quantity, "booking");
|
|
2777
|
-
if (capacity.status === "slot_not_found") {
|
|
2778
|
-
throw new BookingServiceError("slot_not_found");
|
|
2779
|
-
}
|
|
2780
|
-
if (capacity.status === "slot_unavailable") {
|
|
2781
|
-
throw new BookingServiceError("slot_unavailable");
|
|
2782
|
-
}
|
|
2783
|
-
if (capacity.status === "insufficient_capacity") {
|
|
2784
|
-
throw new BookingServiceError("insufficient_capacity");
|
|
2785
|
-
}
|
|
2786
|
-
if (capacity.slotChange)
|
|
2787
|
-
slotChanges.push(capacity.slotChange);
|
|
2788
|
-
}
|
|
2789
|
-
const now = new Date();
|
|
2790
|
-
await tx
|
|
2791
|
-
.update(bookingAllocations)
|
|
2792
|
-
.set({
|
|
2793
|
-
status: "confirmed",
|
|
2794
|
-
confirmedAt: now,
|
|
2795
|
-
releasedAt: null,
|
|
2796
|
-
updatedAt: now,
|
|
2797
|
-
})
|
|
2798
|
-
.where(and(eq(bookingAllocations.bookingId, id), inArray(bookingAllocations.status, ["held", "expired"])));
|
|
2799
|
-
await tx
|
|
2800
|
-
.update(bookingItems)
|
|
2801
|
-
.set({ status: "confirmed", updatedAt: now })
|
|
2802
|
-
.where(and(eq(bookingItems.bookingId, id), inArray(bookingItems.status, ["on_hold", "expired"])));
|
|
2803
|
-
const [row] = await tx
|
|
2804
|
-
.update(bookings)
|
|
2805
|
-
.set({
|
|
2806
|
-
status: "confirmed",
|
|
2807
|
-
confirmedAt: now,
|
|
2808
|
-
paidAt: now,
|
|
2809
|
-
expiredAt: null,
|
|
2810
|
-
holdExpiresAt: null,
|
|
2811
|
-
updatedAt: now,
|
|
2812
|
-
})
|
|
2813
|
-
.where(eq(bookings.id, id))
|
|
2814
|
-
.returning();
|
|
2815
|
-
await syncTransactionOnBookingConfirmed(tx, id);
|
|
2816
|
-
await autoIssueFulfillmentsForBooking(tx, id, userId);
|
|
2817
|
-
await tx.insert(bookingActivityLog).values({
|
|
2818
|
-
bookingId: id,
|
|
2819
|
-
actorId: userId ?? "system",
|
|
2820
|
-
activityType: "booking_confirmed",
|
|
2821
|
-
description: `Late payment recovered and booking ${booking.booking_number} confirmed`,
|
|
2822
|
-
metadata: { recoveredFromStatus: booking.status },
|
|
2823
|
-
});
|
|
2824
|
-
if (data.note) {
|
|
2825
|
-
await tx.insert(bookingNotes).values({
|
|
2826
|
-
bookingId: id,
|
|
2827
|
-
authorId: userId ?? "system",
|
|
2828
|
-
content: data.note,
|
|
2829
|
-
});
|
|
2830
|
-
}
|
|
2831
|
-
await appendBookingStatusMutationLedger(tx, runtime, {
|
|
2832
|
-
actionName: "booking.status.expire",
|
|
2833
|
-
routeOrToolName: "bookings.expire",
|
|
2834
|
-
capabilityId: BOOKING_STATUS_CAPABILITIES.expire.id,
|
|
2835
|
-
bookingId: id,
|
|
2836
|
-
fromStatus: booking.status,
|
|
2837
|
-
toStatus: "expired",
|
|
2838
|
-
});
|
|
2839
|
-
return { status: "ok", booking: row ?? null };
|
|
2840
|
-
});
|
|
2841
|
-
if (result.status === "ok" && result.booking) {
|
|
2842
|
-
await runtime.eventBus?.emit("booking.confirmed", {
|
|
2843
|
-
bookingId: result.booking.id,
|
|
2844
|
-
bookingNumber: result.booking.bookingNumber,
|
|
2845
|
-
actorId: userId ?? null,
|
|
2846
|
-
}, { category: "domain", source: "service" });
|
|
2847
|
-
await emitSlotChanges(runtime, slotChanges);
|
|
2848
|
-
}
|
|
2849
|
-
return result;
|
|
2850
|
-
}
|
|
2851
|
-
catch (error) {
|
|
2852
|
-
if (error instanceof BookingServiceError) {
|
|
2853
|
-
return { status: error.code };
|
|
2854
|
-
}
|
|
2855
|
-
throw error;
|
|
2856
|
-
}
|
|
2857
|
-
},
|
|
2858
|
-
async extendBookingHold(db, id, data, userId) {
|
|
2859
|
-
try {
|
|
2860
|
-
return await db.transaction(async (tx) => {
|
|
2861
|
-
const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
|
|
2862
|
-
FROM ${bookings}
|
|
2863
|
-
WHERE ${bookings.id} = ${id}
|
|
2864
|
-
FOR UPDATE`);
|
|
2865
|
-
const booking = toRows(rows)[0];
|
|
2866
|
-
if (!booking) {
|
|
2867
|
-
throw new BookingServiceError("not_found");
|
|
2868
|
-
}
|
|
2869
|
-
if (booking.status !== "on_hold" && booking.status !== "awaiting_payment") {
|
|
2870
|
-
throw new BookingServiceError("invalid_transition");
|
|
2871
|
-
}
|
|
2872
|
-
if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
|
|
2873
|
-
throw new BookingServiceError("hold_expired");
|
|
2874
|
-
}
|
|
2875
|
-
const holdExpiresAt = await computeHoldExpiresAt(tx, data);
|
|
2876
|
-
await tx
|
|
2877
|
-
.update(bookingAllocations)
|
|
2878
|
-
.set({
|
|
2879
|
-
holdExpiresAt,
|
|
2880
|
-
updatedAt: new Date(),
|
|
2881
|
-
})
|
|
2882
|
-
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
|
|
2883
|
-
const [row] = await tx
|
|
2884
|
-
.update(bookings)
|
|
2885
|
-
.set({
|
|
2886
|
-
holdExpiresAt,
|
|
2887
|
-
updatedAt: new Date(),
|
|
2888
|
-
})
|
|
2889
|
-
.where(eq(bookings.id, id))
|
|
2890
|
-
.returning();
|
|
2891
|
-
await tx.insert(bookingActivityLog).values({
|
|
2892
|
-
bookingId: id,
|
|
2893
|
-
actorId: userId ?? "system",
|
|
2894
|
-
activityType: "hold_extended",
|
|
2895
|
-
description: "Booking hold extended",
|
|
2896
|
-
metadata: { holdExpiresAt: holdExpiresAt.toISOString() },
|
|
2897
|
-
});
|
|
2898
|
-
return { status: "ok", booking: row ?? null };
|
|
2899
|
-
});
|
|
2900
|
-
}
|
|
2901
|
-
catch (error) {
|
|
2902
|
-
if (error instanceof BookingServiceError) {
|
|
2903
|
-
return { status: error.code };
|
|
2904
|
-
}
|
|
2905
|
-
throw error;
|
|
2906
|
-
}
|
|
2907
|
-
},
|
|
2908
|
-
async expireBooking(db, id, data, userId, runtime = {}) {
|
|
2909
|
-
const slotChanges = [];
|
|
2910
|
-
try {
|
|
2911
|
-
const result = await db.transaction(async (tx) => {
|
|
2912
|
-
const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
|
|
2913
|
-
FROM ${bookings}
|
|
2914
|
-
WHERE ${bookings.id} = ${id}
|
|
2915
|
-
FOR UPDATE`);
|
|
2916
|
-
const booking = toRows(rows)[0];
|
|
2917
|
-
if (!booking) {
|
|
2918
|
-
throw new BookingServiceError("not_found");
|
|
2919
|
-
}
|
|
2920
|
-
if (!canTransitionBooking(booking.status, "expired")) {
|
|
2921
|
-
throw new BookingServiceError("invalid_transition");
|
|
2922
|
-
}
|
|
2923
|
-
if (booking.status !== "on_hold") {
|
|
2924
|
-
throw new BookingServiceError("invalid_transition");
|
|
2925
|
-
}
|
|
2926
|
-
const patch = transitionBooking(booking.status, "expired");
|
|
2927
|
-
const allocations = await tx
|
|
2928
|
-
.select()
|
|
2929
|
-
.from(bookingAllocations)
|
|
2930
|
-
.where(eq(bookingAllocations.bookingId, id));
|
|
2931
|
-
for (const allocation of allocations) {
|
|
2932
|
-
const change = await releaseAllocationCapacity(tx, allocation, "expire");
|
|
2933
|
-
if (change)
|
|
2934
|
-
slotChanges.push(change);
|
|
2935
|
-
}
|
|
2936
|
-
await tx
|
|
2937
|
-
.update(bookingAllocations)
|
|
2938
|
-
.set({
|
|
2939
|
-
status: "expired",
|
|
2940
|
-
releasedAt: new Date(),
|
|
2941
|
-
updatedAt: new Date(),
|
|
2942
|
-
})
|
|
2943
|
-
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
|
|
2944
|
-
await tx
|
|
2945
|
-
.update(bookingItems)
|
|
2946
|
-
.set({ status: "expired", updatedAt: new Date() })
|
|
2947
|
-
.where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
|
|
2948
|
-
const [row] = await tx
|
|
2949
|
-
.update(bookings)
|
|
2950
|
-
.set({
|
|
2951
|
-
...patch,
|
|
2952
|
-
holdExpiresAt: null,
|
|
2953
|
-
updatedAt: new Date(),
|
|
2954
|
-
})
|
|
2955
|
-
.where(eq(bookings.id, id))
|
|
2956
|
-
.returning();
|
|
2957
|
-
await syncTransactionOnBookingExpired(tx, id);
|
|
2958
|
-
await runtime.closePaymentSchedulesForBooking?.(tx, id, "expired");
|
|
2959
|
-
await tx.insert(bookingActivityLog).values({
|
|
2960
|
-
bookingId: id,
|
|
2961
|
-
actorId: userId ?? "system",
|
|
2962
|
-
activityType: "hold_expired",
|
|
2963
|
-
description: "Booking hold expired",
|
|
2964
|
-
});
|
|
2965
|
-
if (data.note) {
|
|
2966
|
-
await tx.insert(bookingNotes).values({
|
|
2967
|
-
bookingId: id,
|
|
2968
|
-
authorId: userId ?? "system",
|
|
2969
|
-
content: data.note,
|
|
2970
|
-
});
|
|
2971
|
-
}
|
|
2972
|
-
return { status: "ok", booking: row ?? null };
|
|
2973
|
-
});
|
|
2974
|
-
if (result.status === "ok" && result.booking) {
|
|
2975
|
-
await runtime.eventBus?.emit("booking.expired", {
|
|
2976
|
-
bookingId: result.booking.id,
|
|
2977
|
-
bookingNumber: result.booking.bookingNumber,
|
|
2978
|
-
cause: runtime.cause ?? "route",
|
|
2979
|
-
actorId: userId ?? null,
|
|
2980
|
-
}, { category: "domain", source: "service" });
|
|
2981
|
-
await emitSlotChanges(runtime, slotChanges);
|
|
2982
|
-
await runtime.expirePaymentSessionsForBooking?.(db, result.booking.id);
|
|
2983
|
-
}
|
|
2984
|
-
return result;
|
|
2985
|
-
}
|
|
2986
|
-
catch (error) {
|
|
2987
|
-
if (error instanceof BookingServiceError) {
|
|
2988
|
-
return { status: error.code };
|
|
2989
|
-
}
|
|
2990
|
-
throw error;
|
|
2991
|
-
}
|
|
2992
|
-
},
|
|
2993
|
-
async expireStaleBookings(db, data, userId, runtime = {}) {
|
|
2994
|
-
const cutoff = data.before ? new Date(data.before) : new Date();
|
|
2995
|
-
const staleBookings = await db
|
|
2996
|
-
.select({ id: bookings.id })
|
|
2997
|
-
.from(bookings)
|
|
2998
|
-
.where(and(inArray(bookings.status, ["on_hold", "awaiting_payment"]),
|
|
2999
|
-
// agent-quality: raw-sql reviewed -- owner: bookings; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
3000
|
-
sql `${bookings.holdExpiresAt} IS NOT NULL`, lte(bookings.holdExpiresAt, cutoff)))
|
|
3001
|
-
.orderBy(asc(bookings.holdExpiresAt), asc(bookings.createdAt));
|
|
3002
|
-
const expiredIds = [];
|
|
3003
|
-
for (const booking of staleBookings) {
|
|
3004
|
-
const result = await this.expireBooking(db, booking.id, { note: data.note ?? "Hold expired by sweep" }, userId, { ...runtime, cause: "sweep" });
|
|
3005
|
-
if ("booking" in result && result.booking) {
|
|
3006
|
-
expiredIds.push(result.booking.id);
|
|
3007
|
-
}
|
|
3008
|
-
}
|
|
3009
|
-
return {
|
|
3010
|
-
expiredIds,
|
|
3011
|
-
count: expiredIds.length,
|
|
3012
|
-
cutoff,
|
|
3013
|
-
};
|
|
3014
|
-
},
|
|
3015
|
-
async cancelBooking(db, id, data, userId, runtime = {}) {
|
|
3016
|
-
const slotChanges = [];
|
|
3017
|
-
try {
|
|
3018
|
-
const result = await db.transaction(async (tx) => {
|
|
3019
|
-
const rows = await tx.execute(sql `SELECT id, status
|
|
3020
|
-
FROM ${bookings}
|
|
3021
|
-
WHERE ${bookings.id} = ${id}
|
|
3022
|
-
FOR UPDATE`);
|
|
3023
|
-
const booking = toRows(rows)[0];
|
|
3024
|
-
if (!booking) {
|
|
3025
|
-
throw new BookingServiceError("not_found");
|
|
3026
|
-
}
|
|
3027
|
-
if (!canTransitionBooking(booking.status, "cancelled")) {
|
|
3028
|
-
throw new BookingServiceError("invalid_transition");
|
|
3029
|
-
}
|
|
3030
|
-
const patch = transitionBooking(booking.status, "cancelled");
|
|
3031
|
-
const previousStatus = booking.status;
|
|
3032
|
-
const allocations = await tx
|
|
3033
|
-
.select()
|
|
3034
|
-
.from(bookingAllocations)
|
|
3035
|
-
.where(eq(bookingAllocations.bookingId, id));
|
|
3036
|
-
for (const allocation of allocations) {
|
|
3037
|
-
const change = await releaseAllocationCapacity(tx, allocation, "cancel");
|
|
3038
|
-
if (change)
|
|
3039
|
-
slotChanges.push(change);
|
|
3040
|
-
}
|
|
3041
|
-
await tx
|
|
3042
|
-
.update(bookingAllocations)
|
|
3043
|
-
.set({
|
|
3044
|
-
status: "cancelled",
|
|
3045
|
-
releasedAt: new Date(),
|
|
3046
|
-
updatedAt: new Date(),
|
|
3047
|
-
})
|
|
3048
|
-
.where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
|
|
3049
|
-
await tx
|
|
3050
|
-
.update(bookingItems)
|
|
3051
|
-
.set({
|
|
3052
|
-
status: "cancelled",
|
|
3053
|
-
updatedAt: new Date(),
|
|
3054
|
-
})
|
|
3055
|
-
.where(and(eq(bookingItems.bookingId, id), or(eq(bookingItems.status, "draft"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "confirmed"))));
|
|
3056
|
-
const [row] = await tx
|
|
3057
|
-
.update(bookings)
|
|
3058
|
-
.set({
|
|
3059
|
-
...patch,
|
|
3060
|
-
holdExpiresAt: null,
|
|
3061
|
-
updatedAt: new Date(),
|
|
3062
|
-
})
|
|
3063
|
-
.where(eq(bookings.id, id))
|
|
3064
|
-
.returning();
|
|
3065
|
-
await syncTransactionOnBookingCancelled(tx, id);
|
|
3066
|
-
await runtime.closePaymentSchedulesForBooking?.(tx, id, "cancelled");
|
|
3067
|
-
await tx.insert(bookingActivityLog).values({
|
|
3068
|
-
bookingId: id,
|
|
3069
|
-
actorId: userId ?? "system",
|
|
3070
|
-
activityType: "status_change",
|
|
3071
|
-
description: `Booking cancelled from ${booking.status}`,
|
|
3072
|
-
metadata: { oldStatus: booking.status, newStatus: "cancelled" },
|
|
3073
|
-
});
|
|
3074
|
-
if (data.note) {
|
|
3075
|
-
await tx.insert(bookingNotes).values({
|
|
3076
|
-
bookingId: id,
|
|
3077
|
-
authorId: userId ?? "system",
|
|
3078
|
-
content: data.note,
|
|
3079
|
-
});
|
|
3080
|
-
}
|
|
3081
|
-
// Clean up any booking-group membership (dissolve if ≤1 active members remain).
|
|
3082
|
-
await cleanupGroupOnBookingCancelled(tx, id);
|
|
3083
|
-
await appendBookingStatusMutationLedger(tx, runtime, {
|
|
3084
|
-
actionName: "booking.status.cancel",
|
|
3085
|
-
routeOrToolName: "bookings.cancel",
|
|
3086
|
-
capabilityId: BOOKING_STATUS_CAPABILITIES.cancel.id,
|
|
3087
|
-
bookingId: id,
|
|
3088
|
-
fromStatus: booking.status,
|
|
3089
|
-
toStatus: "cancelled",
|
|
3090
|
-
evaluatedRisk: "high",
|
|
3091
|
-
});
|
|
3092
|
-
return { status: "ok", booking: row ?? null, previousStatus };
|
|
3093
|
-
});
|
|
3094
|
-
if (result.status === "ok" && result.booking) {
|
|
3095
|
-
await runtime.eventBus?.emit("booking.cancelled", {
|
|
3096
|
-
bookingId: result.booking.id,
|
|
3097
|
-
bookingNumber: result.booking.bookingNumber,
|
|
3098
|
-
previousStatus: result.previousStatus,
|
|
3099
|
-
actorId: userId ?? null,
|
|
3100
|
-
}, { category: "domain", source: "service" });
|
|
3101
|
-
await emitSlotChanges(runtime, slotChanges);
|
|
3102
|
-
}
|
|
3103
|
-
return { status: result.status, booking: result.booking };
|
|
3104
|
-
}
|
|
3105
|
-
catch (error) {
|
|
3106
|
-
if (error instanceof BookingServiceError) {
|
|
3107
|
-
return { status: error.code };
|
|
3108
|
-
}
|
|
3109
|
-
throw error;
|
|
3110
|
-
}
|
|
3111
|
-
},
|
|
3112
|
-
async startBooking(db, id, data, userId, runtime = {}) {
|
|
3113
|
-
try {
|
|
3114
|
-
const result = await db.transaction(async (tx) => {
|
|
3115
|
-
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
3116
|
-
FROM ${bookings}
|
|
3117
|
-
WHERE ${bookings.id} = ${id}
|
|
3118
|
-
FOR UPDATE`);
|
|
3119
|
-
const booking = toRows(rows)[0];
|
|
3120
|
-
if (!booking) {
|
|
3121
|
-
throw new BookingServiceError("not_found");
|
|
3122
|
-
}
|
|
3123
|
-
if (!canTransitionBooking(booking.status, "in_progress")) {
|
|
3124
|
-
throw new BookingServiceError("invalid_transition");
|
|
3125
|
-
}
|
|
3126
|
-
const patch = transitionBooking(booking.status, "in_progress");
|
|
3127
|
-
const [row] = await tx
|
|
3128
|
-
.update(bookings)
|
|
3129
|
-
.set({
|
|
3130
|
-
...patch,
|
|
3131
|
-
updatedAt: new Date(),
|
|
3132
|
-
})
|
|
3133
|
-
.where(eq(bookings.id, id))
|
|
3134
|
-
.returning();
|
|
3135
|
-
await tx.insert(bookingActivityLog).values({
|
|
3136
|
-
bookingId: id,
|
|
3137
|
-
actorId: userId ?? "system",
|
|
3138
|
-
activityType: "booking_started",
|
|
3139
|
-
description: `Booking ${booking.booking_number} started`,
|
|
3140
|
-
});
|
|
3141
|
-
if (data.note) {
|
|
3142
|
-
await tx.insert(bookingNotes).values({
|
|
3143
|
-
bookingId: id,
|
|
3144
|
-
authorId: userId ?? "system",
|
|
3145
|
-
content: data.note,
|
|
3146
|
-
});
|
|
3147
|
-
}
|
|
3148
|
-
await appendBookingStatusMutationLedger(tx, runtime, {
|
|
3149
|
-
actionName: "booking.status.start",
|
|
3150
|
-
routeOrToolName: "bookings.start",
|
|
3151
|
-
capabilityId: BOOKING_STATUS_CAPABILITIES.start.id,
|
|
3152
|
-
bookingId: id,
|
|
3153
|
-
fromStatus: booking.status,
|
|
3154
|
-
toStatus: "in_progress",
|
|
3155
|
-
});
|
|
3156
|
-
return { status: "ok", booking: row ?? null };
|
|
3157
|
-
});
|
|
3158
|
-
if (result.status === "ok" && result.booking) {
|
|
3159
|
-
await runtime.eventBus?.emit("booking.started", {
|
|
3160
|
-
bookingId: result.booking.id,
|
|
3161
|
-
bookingNumber: result.booking.bookingNumber,
|
|
3162
|
-
actorId: userId ?? null,
|
|
3163
|
-
}, { category: "domain", source: "service" });
|
|
3164
|
-
}
|
|
3165
|
-
return result;
|
|
3166
|
-
}
|
|
3167
|
-
catch (error) {
|
|
3168
|
-
if (error instanceof BookingServiceError) {
|
|
3169
|
-
return { status: error.code };
|
|
3170
|
-
}
|
|
3171
|
-
throw error;
|
|
3172
|
-
}
|
|
3173
|
-
},
|
|
3174
|
-
async completeBooking(db, id, data, userId, runtime = {}) {
|
|
3175
|
-
try {
|
|
3176
|
-
const result = await db.transaction(async (tx) => {
|
|
3177
|
-
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
3178
|
-
FROM ${bookings}
|
|
3179
|
-
WHERE ${bookings.id} = ${id}
|
|
3180
|
-
FOR UPDATE`);
|
|
3181
|
-
const booking = toRows(rows)[0];
|
|
3182
|
-
if (!booking) {
|
|
3183
|
-
throw new BookingServiceError("not_found");
|
|
3184
|
-
}
|
|
3185
|
-
if (!canTransitionBooking(booking.status, "completed")) {
|
|
3186
|
-
throw new BookingServiceError("invalid_transition");
|
|
3187
|
-
}
|
|
3188
|
-
const patch = transitionBooking(booking.status, "completed");
|
|
3189
|
-
await tx
|
|
3190
|
-
.update(bookingAllocations)
|
|
3191
|
-
.set({ status: "fulfilled", updatedAt: new Date() })
|
|
3192
|
-
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "confirmed")));
|
|
3193
|
-
await tx
|
|
3194
|
-
.update(bookingItems)
|
|
3195
|
-
.set({ status: "fulfilled", updatedAt: new Date() })
|
|
3196
|
-
.where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "confirmed")));
|
|
3197
|
-
const [row] = await tx
|
|
3198
|
-
.update(bookings)
|
|
3199
|
-
.set({
|
|
3200
|
-
...patch,
|
|
3201
|
-
updatedAt: new Date(),
|
|
3202
|
-
})
|
|
3203
|
-
.where(eq(bookings.id, id))
|
|
3204
|
-
.returning();
|
|
3205
|
-
await tx.insert(bookingActivityLog).values({
|
|
3206
|
-
bookingId: id,
|
|
3207
|
-
actorId: userId ?? "system",
|
|
3208
|
-
activityType: "booking_completed",
|
|
3209
|
-
description: `Booking ${booking.booking_number} completed`,
|
|
3210
|
-
});
|
|
3211
|
-
if (data.note) {
|
|
3212
|
-
await tx.insert(bookingNotes).values({
|
|
3213
|
-
bookingId: id,
|
|
3214
|
-
authorId: userId ?? "system",
|
|
3215
|
-
content: data.note,
|
|
3216
|
-
});
|
|
3217
|
-
}
|
|
3218
|
-
await appendBookingStatusMutationLedger(tx, runtime, {
|
|
3219
|
-
actionName: "booking.status.complete",
|
|
3220
|
-
routeOrToolName: "bookings.complete",
|
|
3221
|
-
capabilityId: BOOKING_STATUS_CAPABILITIES.complete.id,
|
|
3222
|
-
bookingId: id,
|
|
3223
|
-
fromStatus: booking.status,
|
|
3224
|
-
toStatus: "completed",
|
|
3225
|
-
});
|
|
3226
|
-
return { status: "ok", booking: row ?? null };
|
|
3227
|
-
});
|
|
3228
|
-
if (result.status === "ok" && result.booking) {
|
|
3229
|
-
await runtime.eventBus?.emit("booking.completed", {
|
|
3230
|
-
bookingId: result.booking.id,
|
|
3231
|
-
bookingNumber: result.booking.bookingNumber,
|
|
3232
|
-
actorId: userId ?? null,
|
|
3233
|
-
}, { category: "domain", source: "service" });
|
|
3234
|
-
}
|
|
3235
|
-
return result;
|
|
3236
|
-
}
|
|
3237
|
-
catch (error) {
|
|
3238
|
-
if (error instanceof BookingServiceError) {
|
|
3239
|
-
return { status: error.code };
|
|
3240
|
-
}
|
|
3241
|
-
throw error;
|
|
3242
|
-
}
|
|
3243
|
-
},
|
|
3244
|
-
/**
|
|
3245
|
-
* Admin-only force: bypasses the transition graph. Terminal overrides
|
|
3246
|
-
* cascade to items and allocations so reporting and capacity stay
|
|
3247
|
-
* consistent; non-terminal overrides remain booking-row data correction.
|
|
3248
|
-
*/
|
|
3249
|
-
async overrideBookingStatus(db, id, data, userId, runtime = {}) {
|
|
3250
|
-
const slotChanges = [];
|
|
3251
|
-
try {
|
|
3252
|
-
const result = await db.transaction(async (tx) => {
|
|
3253
|
-
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
3254
|
-
FROM ${bookings}
|
|
3255
|
-
WHERE ${bookings.id} = ${id}
|
|
3256
|
-
FOR UPDATE`);
|
|
3257
|
-
const booking = toRows(rows)[0];
|
|
3258
|
-
if (!booking) {
|
|
3259
|
-
throw new BookingServiceError("not_found");
|
|
3260
|
-
}
|
|
3261
|
-
const now = new Date();
|
|
3262
|
-
const terminalItemStatus = terminalBookingItemStatusForOverride(data.status);
|
|
3263
|
-
const terminalAllocationStatus = terminalBookingAllocationStatusForOverride(data.status);
|
|
3264
|
-
const updates = {
|
|
3265
|
-
status: data.status,
|
|
3266
|
-
confirmedAt: confirmedAtForStatus(data.status, null, now),
|
|
3267
|
-
updatedAt: now,
|
|
3268
|
-
};
|
|
3269
|
-
if (data.status === "expired")
|
|
3270
|
-
updates.expiredAt = now;
|
|
3271
|
-
if (data.status === "cancelled")
|
|
3272
|
-
updates.cancelledAt = now;
|
|
3273
|
-
if (data.status === "completed")
|
|
3274
|
-
updates.completedAt = now;
|
|
3275
|
-
if (terminalItemStatus && terminalAllocationStatus) {
|
|
3276
|
-
if (data.status === "cancelled" || data.status === "expired") {
|
|
3277
|
-
const allocations = await tx
|
|
3278
|
-
.select()
|
|
3279
|
-
.from(bookingAllocations)
|
|
3280
|
-
.where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"), eq(bookingAllocations.status, "fulfilled"))));
|
|
3281
|
-
for (const allocation of allocations) {
|
|
3282
|
-
const change = await releaseAllocationCapacity(tx, allocation, data.status === "expired" ? "expire" : "cancel");
|
|
3283
|
-
if (change)
|
|
3284
|
-
slotChanges.push(change);
|
|
3285
|
-
}
|
|
3286
|
-
}
|
|
3287
|
-
const allocationUpdates = {
|
|
3288
|
-
status: terminalAllocationStatus,
|
|
3289
|
-
updatedAt: now,
|
|
3290
|
-
};
|
|
3291
|
-
if (data.status === "cancelled" || data.status === "expired") {
|
|
3292
|
-
allocationUpdates.releasedAt = now;
|
|
3293
|
-
}
|
|
3294
|
-
await tx
|
|
3295
|
-
.update(bookingAllocations)
|
|
3296
|
-
.set(allocationUpdates)
|
|
3297
|
-
.where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"), eq(bookingAllocations.status, "fulfilled"))));
|
|
3298
|
-
await tx
|
|
3299
|
-
.update(bookingItems)
|
|
3300
|
-
.set({ status: terminalItemStatus, updatedAt: now })
|
|
3301
|
-
.where(and(eq(bookingItems.bookingId, id), or(eq(bookingItems.status, "draft"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "confirmed"), eq(bookingItems.status, "fulfilled"))));
|
|
3302
|
-
}
|
|
3303
|
-
const [row] = await tx.update(bookings).set(updates).where(eq(bookings.id, id)).returning();
|
|
3304
|
-
if (data.status === "cancelled" || data.status === "expired") {
|
|
3305
|
-
await runtime.closePaymentSchedulesForBooking?.(tx, id, data.status);
|
|
3306
|
-
}
|
|
3307
|
-
await tx.insert(bookingActivityLog).values({
|
|
3308
|
-
bookingId: id,
|
|
3309
|
-
actorId: userId ?? "system",
|
|
3310
|
-
activityType: "status_overridden",
|
|
3311
|
-
description: `Booking status overridden from ${booking.status} to ${data.status}`,
|
|
3312
|
-
metadata: {
|
|
3313
|
-
oldStatus: booking.status,
|
|
3314
|
-
newStatus: data.status,
|
|
3315
|
-
reason: data.reason,
|
|
3316
|
-
},
|
|
3317
|
-
});
|
|
3318
|
-
if (data.note) {
|
|
3319
|
-
await tx.insert(bookingNotes).values({
|
|
3320
|
-
bookingId: id,
|
|
3321
|
-
authorId: userId ?? "system",
|
|
3322
|
-
content: data.note,
|
|
3323
|
-
});
|
|
3324
|
-
}
|
|
3325
|
-
await appendBookingStatusMutationLedger(tx, runtime, {
|
|
3326
|
-
actionName: "booking.status.override",
|
|
3327
|
-
routeOrToolName: "bookings.override-status",
|
|
3328
|
-
capabilityId: BOOKING_STATUS_CAPABILITIES.override.id,
|
|
3329
|
-
bookingId: id,
|
|
3330
|
-
fromStatus: booking.status,
|
|
3331
|
-
toStatus: data.status,
|
|
3332
|
-
evaluatedRisk: "high",
|
|
3333
|
-
});
|
|
3334
|
-
return {
|
|
3335
|
-
status: "ok",
|
|
3336
|
-
booking: row ?? null,
|
|
3337
|
-
fromStatus: booking.status,
|
|
3338
|
-
toStatus: data.status,
|
|
3339
|
-
};
|
|
3340
|
-
});
|
|
3341
|
-
if (result.status === "ok" && result.booking) {
|
|
3342
|
-
await runtime.eventBus?.emit("booking.status_overridden", {
|
|
3343
|
-
bookingId: result.booking.id,
|
|
3344
|
-
bookingNumber: result.booking.bookingNumber,
|
|
3345
|
-
fromStatus: result.fromStatus,
|
|
3346
|
-
toStatus: result.toStatus,
|
|
3347
|
-
reason: data.reason,
|
|
3348
|
-
actorId: userId ?? null,
|
|
3349
|
-
}, { category: "domain", source: "service" });
|
|
3350
|
-
// Keep draft → confirmed overrides compatible with the create dialog,
|
|
3351
|
-
// but let data-correction callers preserve the audit event without
|
|
3352
|
-
// re-running the full confirm lifecycle.
|
|
3353
|
-
if (result.toStatus === "confirmed" && data.suppressLifecycleEvents !== true) {
|
|
3354
|
-
await runtime.eventBus?.emit("booking.confirmed", {
|
|
3355
|
-
bookingId: result.booking.id,
|
|
3356
|
-
bookingNumber: result.booking.bookingNumber,
|
|
3357
|
-
actorId: userId ?? null,
|
|
3358
|
-
suppressNotifications: data.suppressNotifications === true,
|
|
3359
|
-
}, { category: "domain", source: "service" });
|
|
3360
|
-
}
|
|
3361
|
-
await emitSlotChanges(runtime, slotChanges);
|
|
3362
|
-
}
|
|
3363
|
-
return { status: result.status, booking: result.booking };
|
|
3364
|
-
}
|
|
3365
|
-
catch (error) {
|
|
3366
|
-
if (error instanceof BookingServiceError) {
|
|
3367
|
-
return { status: error.code };
|
|
3368
|
-
}
|
|
3369
|
-
throw error;
|
|
3370
|
-
}
|
|
3371
|
-
},
|
|
3372
|
-
listTravelerRecords(db, bookingId) {
|
|
3373
|
-
return db
|
|
3374
|
-
.select()
|
|
3375
|
-
.from(bookingTravelers)
|
|
3376
|
-
.where(eq(bookingTravelers.bookingId, bookingId))
|
|
3377
|
-
.orderBy(desc(bookingTravelers.isPrimary), asc(bookingTravelers.createdAt));
|
|
3378
|
-
},
|
|
3379
|
-
async getTravelerRecordById(db, bookingId, travelerId) {
|
|
3380
|
-
const [row] = await db
|
|
3381
|
-
.select()
|
|
3382
|
-
.from(bookingTravelers)
|
|
3383
|
-
.where(and(eq(bookingTravelers.id, travelerId), eq(bookingTravelers.bookingId, bookingId)))
|
|
3384
|
-
.limit(1);
|
|
3385
|
-
return row ?? null;
|
|
3386
|
-
},
|
|
3387
|
-
async createTravelerRecord(db, bookingId, data, userId) {
|
|
3388
|
-
const [booking] = await db
|
|
3389
|
-
.select({ id: bookings.id })
|
|
3390
|
-
.from(bookings)
|
|
3391
|
-
.where(eq(bookings.id, bookingId))
|
|
3392
|
-
.limit(1);
|
|
3393
|
-
if (!booking) {
|
|
3394
|
-
return null;
|
|
3395
|
-
}
|
|
3396
|
-
const [row] = await db
|
|
3397
|
-
.insert(bookingTravelers)
|
|
3398
|
-
.values({
|
|
3399
|
-
bookingId,
|
|
3400
|
-
personId: data.personId ?? null,
|
|
3401
|
-
participantType: data.participantType,
|
|
3402
|
-
travelerCategory: data.travelerCategory ?? null,
|
|
3403
|
-
firstName: data.firstName,
|
|
3404
|
-
lastName: data.lastName,
|
|
3405
|
-
email: data.email ?? null,
|
|
3406
|
-
phone: data.phone ?? null,
|
|
3407
|
-
preferredLanguage: data.preferredLanguage ?? null,
|
|
3408
|
-
specialRequests: data.specialRequests ?? null,
|
|
3409
|
-
isPrimary: data.isPrimary ?? false,
|
|
3410
|
-
notes: data.notes ?? null,
|
|
3411
|
-
})
|
|
3412
|
-
.returning();
|
|
3413
|
-
if (!row) {
|
|
3414
|
-
return null;
|
|
3415
|
-
}
|
|
3416
|
-
await ensureParticipantFlags(db, bookingId, row.id, data);
|
|
3417
|
-
await db.insert(bookingActivityLog).values({
|
|
3418
|
-
bookingId,
|
|
3419
|
-
actorId: userId ?? "system",
|
|
3420
|
-
activityType: "traveler_update",
|
|
3421
|
-
description: `Traveler ${data.firstName} ${data.lastName} added`,
|
|
3422
|
-
metadata: { travelerId: row.id, participantType: data.participantType },
|
|
3423
|
-
});
|
|
3424
|
-
return row;
|
|
3425
|
-
},
|
|
3426
|
-
async updateTravelerRecord(db, travelerId, data) {
|
|
3427
|
-
const [row] = await db
|
|
3428
|
-
.update(bookingTravelers)
|
|
3429
|
-
.set({ ...data, updatedAt: new Date() })
|
|
3430
|
-
.where(eq(bookingTravelers.id, travelerId))
|
|
3431
|
-
.returning();
|
|
3432
|
-
if (!row) {
|
|
3433
|
-
return null;
|
|
3434
|
-
}
|
|
3435
|
-
await ensureParticipantFlags(db, row.bookingId, row.id, data);
|
|
3436
|
-
return row;
|
|
3437
|
-
},
|
|
3438
|
-
/**
|
|
3439
|
-
* Create a traveler row and persist the encrypted travel-details envelope in
|
|
3440
|
-
* a single call. Migration boundary helper for consumers coming from the
|
|
3441
|
-
* pre-0.10 `createTravelerRecord({ ..., accessibilityNeeds, ... })` shape:
|
|
3442
|
-
* the storage split (plaintext columns + encrypted bucket) is preserved, but
|
|
3443
|
-
* the call ergonomics collapse back to one flat payload. Plaintext fields go
|
|
3444
|
-
* to `createTravelerRecord`; encrypted fields are forwarded to
|
|
3445
|
-
* `pii.upsertTravelerTravelDetails`. Operations are sequential, not
|
|
3446
|
-
* transactional — a failure in the encrypted-fields write leaves the
|
|
3447
|
-
* plaintext row in place (matching the pre-helper two-call protocol).
|
|
3448
|
-
*/
|
|
3449
|
-
async createTravelerWithTravelDetails(db, bookingId, data, opts) {
|
|
3450
|
-
const traveler = await this.createTravelerRecord(db, bookingId, {
|
|
3451
|
-
personId: data.personId,
|
|
3452
|
-
participantType: data.participantType,
|
|
3453
|
-
travelerCategory: data.travelerCategory,
|
|
3454
|
-
firstName: data.firstName,
|
|
3455
|
-
lastName: data.lastName,
|
|
3456
|
-
email: data.email,
|
|
3457
|
-
phone: data.phone,
|
|
3458
|
-
preferredLanguage: data.preferredLanguage,
|
|
3459
|
-
specialRequests: data.specialRequests,
|
|
3460
|
-
isPrimary: data.isPrimary,
|
|
3461
|
-
notes: data.notes,
|
|
3462
|
-
}, opts.userId);
|
|
3463
|
-
if (!traveler) {
|
|
3464
|
-
return null;
|
|
3465
|
-
}
|
|
3466
|
-
let travelDetailInput = pickTravelDetailFields(data);
|
|
3467
|
-
if (data.personId && opts.resolveTravelSnapshot) {
|
|
3468
|
-
const snapshot = await opts.resolveTravelSnapshot(data.personId);
|
|
3469
|
-
travelDetailInput = applyTravelDetailSnapshot(travelDetailInput, snapshot);
|
|
3470
|
-
}
|
|
3471
|
-
const travelDetails = await persistTravelDetailsWithCapacityCheck(db, traveler.id, travelDetailInput, opts);
|
|
3472
|
-
return { traveler, travelDetails };
|
|
3473
|
-
},
|
|
3474
|
-
/**
|
|
3475
|
-
* Update a traveler row and (re-)upsert the encrypted travel-details
|
|
3476
|
-
* envelope in a single call. Same migration-ergonomics motivation as
|
|
3477
|
-
* `createTravelerWithTravelDetails`. Undefined fields are not written;
|
|
3478
|
-
* `null` clears a column / encrypted field.
|
|
3479
|
-
*/
|
|
3480
|
-
async updateTravelerWithTravelDetails(db, travelerId, data, opts) {
|
|
3481
|
-
const traveler = await this.updateTravelerRecord(db, travelerId, {
|
|
3482
|
-
personId: data.personId,
|
|
3483
|
-
participantType: data.participantType,
|
|
3484
|
-
travelerCategory: data.travelerCategory,
|
|
3485
|
-
firstName: data.firstName,
|
|
3486
|
-
lastName: data.lastName,
|
|
3487
|
-
email: data.email,
|
|
3488
|
-
phone: data.phone,
|
|
3489
|
-
preferredLanguage: data.preferredLanguage,
|
|
3490
|
-
specialRequests: data.specialRequests,
|
|
3491
|
-
isPrimary: data.isPrimary,
|
|
3492
|
-
notes: data.notes,
|
|
3493
|
-
});
|
|
3494
|
-
if (!traveler) {
|
|
3495
|
-
return null;
|
|
3496
|
-
}
|
|
3497
|
-
const travelDetailInput = pickTravelDetailFields(data);
|
|
3498
|
-
const travelDetails = await persistTravelDetailsWithCapacityCheck(db, traveler.id, travelDetailInput, opts);
|
|
3499
|
-
return { traveler, travelDetails };
|
|
3500
|
-
},
|
|
3501
|
-
async deleteTravelerRecord(db, travelerId) {
|
|
3502
|
-
const [row] = await db
|
|
3503
|
-
.delete(bookingTravelers)
|
|
3504
|
-
.where(eq(bookingTravelers.id, travelerId))
|
|
3505
|
-
.returning({ id: bookingTravelers.id });
|
|
3506
|
-
return row ?? null;
|
|
3507
|
-
},
|
|
3508
|
-
listTravelers(db, bookingId) {
|
|
3509
|
-
return db
|
|
3510
|
-
.select()
|
|
3511
|
-
.from(bookingTravelers)
|
|
3512
|
-
.where(and(eq(bookingTravelers.bookingId, bookingId), or(...travelerParticipantTypes.map((type) => eq(bookingTravelers.participantType, type)))))
|
|
3513
|
-
.orderBy(asc(bookingTravelers.createdAt))
|
|
3514
|
-
.then((rows) => rows.map(toTravelerResponse));
|
|
3515
|
-
},
|
|
3516
|
-
async listSharingGroupsForSlot(db, slotId) {
|
|
3517
|
-
const rows = await db
|
|
3518
|
-
.select({
|
|
3519
|
-
id: bookingTravelerTravelDetails.sharingGroupId,
|
|
3520
|
-
occupancy: sql `count(distinct ${bookingTravelers.id})::int`,
|
|
3521
|
-
roomTypeId: sql `
|
|
3522
|
-
case
|
|
3523
|
-
when count(distinct ${bookingTravelerTravelDetails.roomTypeId})
|
|
3524
|
-
filter (where ${bookingTravelerTravelDetails.roomTypeId} is not null) = 1
|
|
3525
|
-
then min(${bookingTravelerTravelDetails.roomTypeId})
|
|
3526
|
-
else null
|
|
3527
|
-
end
|
|
3528
|
-
`,
|
|
3529
|
-
bookingIds: sql `
|
|
3530
|
-
array_agg(distinct ${bookingTravelers.bookingId} order by ${bookingTravelers.bookingId})
|
|
3531
|
-
`,
|
|
3532
|
-
})
|
|
3533
|
-
.from(bookingTravelerTravelDetails)
|
|
3534
|
-
.innerJoin(bookingTravelers, eq(bookingTravelers.id, bookingTravelerTravelDetails.travelerId))
|
|
3535
|
-
.innerJoin(bookings, eq(bookings.id, bookingTravelers.bookingId))
|
|
3536
|
-
.innerJoin(bookingAllocations, eq(bookingAllocations.bookingId, bookings.id))
|
|
3537
|
-
.where(and(eq(bookingAllocations.availabilitySlotId, slotId), isNotNull(bookingTravelerTravelDetails.sharingGroupId), ne(bookingTravelerTravelDetails.sharingGroupId, ""), inArray(bookings.status, sharingGroupBookingStatuses), inArray(bookingAllocations.status, sharingGroupAllocationStatuses), or(...travelerParticipantTypes.map((type) => eq(bookingTravelers.participantType, type)))))
|
|
3538
|
-
.groupBy(bookingTravelerTravelDetails.sharingGroupId)
|
|
3539
|
-
.orderBy(asc(bookingTravelerTravelDetails.sharingGroupId));
|
|
3540
|
-
return rows.flatMap((row) => {
|
|
3541
|
-
if (!row.id)
|
|
3542
|
-
return [];
|
|
3543
|
-
return [
|
|
3544
|
-
{
|
|
3545
|
-
id: row.id,
|
|
3546
|
-
label: row.id,
|
|
3547
|
-
occupancy: row.occupancy,
|
|
3548
|
-
roomTypeId: row.roomTypeId,
|
|
3549
|
-
bookingIds: row.bookingIds,
|
|
3550
|
-
},
|
|
3551
|
-
];
|
|
3552
|
-
});
|
|
3553
|
-
},
|
|
3554
|
-
async listTravelersBySharingGroup(db, slotId, sharingGroupId) {
|
|
3555
|
-
const rows = await db
|
|
3556
|
-
.selectDistinct({
|
|
3557
|
-
id: bookingTravelers.id,
|
|
3558
|
-
bookingId: bookingTravelers.bookingId,
|
|
3559
|
-
bookingNumber: bookings.bookingNumber,
|
|
3560
|
-
participantType: bookingTravelers.participantType,
|
|
3561
|
-
travelerCategory: bookingTravelers.travelerCategory,
|
|
3562
|
-
personId: bookingTravelers.personId,
|
|
3563
|
-
firstName: bookingTravelers.firstName,
|
|
3564
|
-
lastName: bookingTravelers.lastName,
|
|
3565
|
-
email: bookingTravelers.email,
|
|
3566
|
-
phone: bookingTravelers.phone,
|
|
3567
|
-
preferredLanguage: bookingTravelers.preferredLanguage,
|
|
3568
|
-
specialRequests: bookingTravelers.specialRequests,
|
|
3569
|
-
isPrimary: bookingTravelers.isPrimary,
|
|
3570
|
-
notes: bookingTravelers.notes,
|
|
3571
|
-
isLeadTraveler: bookingTravelerTravelDetails.isLeadTraveler,
|
|
3572
|
-
sharingGroupId: bookingTravelerTravelDetails.sharingGroupId,
|
|
3573
|
-
roomTypeId: bookingTravelerTravelDetails.roomTypeId,
|
|
3574
|
-
bedPreference: bookingTravelerTravelDetails.bedPreference,
|
|
3575
|
-
allocations: bookingTravelerTravelDetails.allocations,
|
|
3576
|
-
createdAt: bookingTravelers.createdAt,
|
|
3577
|
-
updatedAt: bookingTravelers.updatedAt,
|
|
3578
|
-
})
|
|
3579
|
-
.from(bookingTravelers)
|
|
3580
|
-
.innerJoin(bookingTravelerTravelDetails, eq(bookingTravelerTravelDetails.travelerId, bookingTravelers.id))
|
|
3581
|
-
.innerJoin(bookings, eq(bookings.id, bookingTravelers.bookingId))
|
|
3582
|
-
.innerJoin(bookingAllocations, eq(bookingAllocations.bookingId, bookings.id))
|
|
3583
|
-
.where(and(eq(bookingAllocations.availabilitySlotId, slotId), eq(bookingTravelerTravelDetails.sharingGroupId, sharingGroupId), inArray(bookings.status, sharingGroupBookingStatuses), inArray(bookingAllocations.status, sharingGroupAllocationStatuses), or(...travelerParticipantTypes.map((type) => eq(bookingTravelers.participantType, type)))))
|
|
3584
|
-
.orderBy(asc(bookings.bookingNumber), desc(bookingTravelers.isPrimary), asc(bookingTravelers.createdAt));
|
|
3585
|
-
return rows.flatMap((row) => {
|
|
3586
|
-
if (!row.sharingGroupId)
|
|
3587
|
-
return [];
|
|
3588
|
-
return [
|
|
3589
|
-
{
|
|
3590
|
-
...row,
|
|
3591
|
-
isLeadTraveler: row.isLeadTraveler,
|
|
3592
|
-
sharingGroupId: row.sharingGroupId,
|
|
3593
|
-
allocations: normalizeTravelerAllocationMap(row.allocations),
|
|
3594
|
-
},
|
|
3595
|
-
];
|
|
3596
|
-
});
|
|
3597
|
-
},
|
|
3598
|
-
async createTraveler(db, bookingId, data, userId) {
|
|
3599
|
-
const row = await this.createTravelerRecord(db, bookingId, {
|
|
3600
|
-
participantType: "traveler",
|
|
3601
|
-
travelerCategory: data.travelerCategory ?? null,
|
|
3602
|
-
firstName: data.firstName,
|
|
3603
|
-
lastName: data.lastName,
|
|
3604
|
-
email: data.email ?? null,
|
|
3605
|
-
phone: data.phone ?? null,
|
|
3606
|
-
preferredLanguage: data.preferredLanguage ?? null,
|
|
3607
|
-
specialRequests: data.specialRequests ?? null,
|
|
3608
|
-
isPrimary: data.isPrimary ?? false,
|
|
3609
|
-
notes: data.notes ?? null,
|
|
3610
|
-
}, userId);
|
|
3611
|
-
return row ? toTravelerResponse(row) : null;
|
|
3612
|
-
},
|
|
3613
|
-
async updateTraveler(db, travelerId, data) {
|
|
3614
|
-
const row = await this.updateTravelerRecord(db, travelerId, {
|
|
3615
|
-
firstName: data.firstName,
|
|
3616
|
-
lastName: data.lastName,
|
|
3617
|
-
email: data.email ?? null,
|
|
3618
|
-
phone: data.phone ?? null,
|
|
3619
|
-
preferredLanguage: data.preferredLanguage ?? null,
|
|
3620
|
-
specialRequests: data.specialRequests ?? null,
|
|
3621
|
-
travelerCategory: data.travelerCategory ?? null,
|
|
3622
|
-
isPrimary: data.isPrimary ?? undefined,
|
|
3623
|
-
notes: data.notes ?? null,
|
|
3624
|
-
});
|
|
3625
|
-
return row ? toTravelerResponse(row) : null;
|
|
3626
|
-
},
|
|
3627
|
-
async deleteTraveler(db, travelerId) {
|
|
3628
|
-
return this.deleteTravelerRecord(db, travelerId);
|
|
3629
|
-
},
|
|
3630
|
-
/**
|
|
3631
|
-
* Lists booking items. Snapshot columns
|
|
3632
|
-
* (`productNameSnapshot`/`optionNameSnapshot`/`unitNameSnapshot`/
|
|
3633
|
-
* `departureLabelSnapshot`) travel with the row, so consumers see
|
|
3634
|
-
* exactly what was sold at booking time — no JOIN required, works in
|
|
3635
|
-
* catalog-less deployments, survives product deletion or renames.
|
|
3636
|
-
*/
|
|
3637
|
-
listItems(db, bookingId) {
|
|
3638
|
-
return db
|
|
3639
|
-
.select()
|
|
3640
|
-
.from(bookingItems)
|
|
3641
|
-
.where(eq(bookingItems.bookingId, bookingId))
|
|
3642
|
-
.orderBy(asc(bookingItems.createdAt));
|
|
3643
|
-
},
|
|
3644
|
-
/**
|
|
3645
|
-
* Re-derive `bookings.sellAmountCents` / `costAmountCents` from
|
|
3646
|
-
* `Σ(booking_items.total*AmountCents)`, plus — when the booking
|
|
3647
|
-
* declares a `baseCurrency` and `fxRateSetId` — re-derive
|
|
3648
|
-
* `baseSellAmountCents` / `baseCostAmountCents` by converting each
|
|
3649
|
-
* item's total via the FX rate set.
|
|
3650
|
-
*
|
|
3651
|
-
* Called automatically inside the item-mutation methods so callers
|
|
3652
|
-
* that go through `createItem` / `updateItem` / `deleteItem` never
|
|
3653
|
-
* have to remember to roll the parent. Public so external flows
|
|
3654
|
-
* (saga compensations, ad-hoc fix-ups) can also invoke it.
|
|
3655
|
-
*
|
|
3656
|
-
* Pass a tx-bound `db` to compose with an existing transaction; this
|
|
3657
|
-
* method does NOT wrap its own transaction.
|
|
3658
|
-
*
|
|
3659
|
-
* **FX rollup behaviour**:
|
|
3660
|
-
*
|
|
3661
|
-
* - Single-currency booking (every item's `sellCurrency === baseCurrency`,
|
|
3662
|
-
* or `baseCurrency === sellCurrency` on the parent): `base*Cents`
|
|
3663
|
-
* equal `sell*Cents` / `cost*Cents` directly. No FX lookup needed.
|
|
3664
|
-
* - Multi-currency booking with `fxRateSetId`: every item is
|
|
3665
|
-
* converted to `baseCurrency` via `exchange_rates`. If any item's
|
|
3666
|
-
* currency is missing from the rate set, the FX rollup short-circuits
|
|
3667
|
-
* with `fxStatus: "missing_rate"` and `base*Cents` are LEFT
|
|
3668
|
-
* UNCHANGED on the parent (caller chooses whether to abort).
|
|
3669
|
-
* - No `baseCurrency` configured: FX rollup is skipped entirely
|
|
3670
|
-
* (`fxStatus: "skipped"`), and `base*Cents` stay null.
|
|
3671
|
-
*
|
|
3672
|
-
* Returns `{ sellAmountCents, costAmountCents, baseSellAmountCents,
|
|
3673
|
-
* baseCostAmountCents, fxStatus, missingCurrency? }` or `null` for a
|
|
3674
|
-
* missing booking.
|
|
3675
|
-
*/
|
|
3676
|
-
async recomputeBookingTotal(db, bookingId) {
|
|
3677
|
-
const [booking] = await db
|
|
3678
|
-
.select({
|
|
3679
|
-
id: bookings.id,
|
|
3680
|
-
sellCurrency: bookings.sellCurrency,
|
|
3681
|
-
baseCurrency: bookings.baseCurrency,
|
|
3682
|
-
})
|
|
3683
|
-
.from(bookings)
|
|
3684
|
-
.where(eq(bookings.id, bookingId))
|
|
3685
|
-
.limit(1);
|
|
3686
|
-
if (!booking) {
|
|
3687
|
-
return null;
|
|
3688
|
-
}
|
|
3689
|
-
const [totals] = await db
|
|
3690
|
-
.select({
|
|
3691
|
-
sellAmountCents: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::int`,
|
|
3692
|
-
costAmountCents: sql `coalesce(sum(${bookingItems.totalCostAmountCents}), 0)::int`,
|
|
3693
|
-
})
|
|
3694
|
-
.from(bookingItems)
|
|
3695
|
-
.where(eq(bookingItems.bookingId, bookingId));
|
|
3696
|
-
const sellAmountCents = totals?.sellAmountCents ?? 0;
|
|
3697
|
-
const costAmountCents = totals?.costAmountCents ?? 0;
|
|
3698
|
-
// We need fxRateSetId from the bookings row plus per-item currency
|
|
3699
|
-
// for the FX rollup. Refetch with those columns.
|
|
3700
|
-
const [bookingForFx] = await db
|
|
3701
|
-
.select({
|
|
3702
|
-
baseCurrency: bookings.baseCurrency,
|
|
3703
|
-
sellCurrency: bookings.sellCurrency,
|
|
3704
|
-
})
|
|
3705
|
-
.from(bookings)
|
|
3706
|
-
.where(eq(bookings.id, bookingId))
|
|
3707
|
-
.limit(1);
|
|
3708
|
-
let fxStatus = "skipped";
|
|
3709
|
-
let baseSellAmountCents = null;
|
|
3710
|
-
let baseCostAmountCents = null;
|
|
3711
|
-
let missingCurrency = null;
|
|
3712
|
-
const baseCurrency = bookingForFx?.baseCurrency ?? null;
|
|
3713
|
-
if (baseCurrency) {
|
|
3714
|
-
const fxResult = await rollupBaseTotals(db, bookingId, baseCurrency);
|
|
3715
|
-
if (fxResult.status === "ok") {
|
|
3716
|
-
fxStatus = "ok";
|
|
3717
|
-
baseSellAmountCents = fxResult.baseSellAmountCents;
|
|
3718
|
-
baseCostAmountCents = fxResult.baseCostAmountCents;
|
|
3719
|
-
}
|
|
3720
|
-
else if (fxResult.status === "missing_rate") {
|
|
3721
|
-
fxStatus = "missing_rate";
|
|
3722
|
-
missingCurrency = fxResult.currency;
|
|
3723
|
-
}
|
|
3724
|
-
}
|
|
3725
|
-
const patch = {
|
|
3726
|
-
sellAmountCents,
|
|
3727
|
-
costAmountCents,
|
|
3728
|
-
updatedAt: new Date(),
|
|
3729
|
-
};
|
|
3730
|
-
if (fxStatus === "ok") {
|
|
3731
|
-
patch.baseSellAmountCents = baseSellAmountCents;
|
|
3732
|
-
patch.baseCostAmountCents = baseCostAmountCents;
|
|
3733
|
-
}
|
|
3734
|
-
await db.update(bookings).set(patch).where(eq(bookings.id, bookingId));
|
|
3735
|
-
return {
|
|
3736
|
-
sellAmountCents,
|
|
3737
|
-
costAmountCents,
|
|
3738
|
-
baseSellAmountCents,
|
|
3739
|
-
baseCostAmountCents,
|
|
3740
|
-
fxStatus,
|
|
3741
|
-
...(missingCurrency ? { missingCurrency } : {}),
|
|
3742
|
-
};
|
|
3743
|
-
},
|
|
3744
|
-
async createItem(db, bookingId, data, userId) {
|
|
3745
|
-
return db.transaction(async (tx) => {
|
|
3746
|
-
const [booking] = await tx
|
|
3747
|
-
.select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
|
|
3748
|
-
.from(bookings)
|
|
3749
|
-
.where(eq(bookings.id, bookingId))
|
|
3750
|
-
.limit(1);
|
|
3751
|
-
if (!booking) {
|
|
3752
|
-
return null;
|
|
3753
|
-
}
|
|
3754
|
-
// Look up catalog/availability data for snapshotting + auto-fill.
|
|
3755
|
-
// Explicit input values win — callers in catalog-less deployments
|
|
3756
|
-
// (OTA) pass their own snapshots/timings and we don't touch them.
|
|
3757
|
-
const enrichment = await resolveBookingItemSnapshot(tx, {
|
|
3758
|
-
productId: data.productId ?? null,
|
|
3759
|
-
optionId: data.optionId ?? null,
|
|
3760
|
-
optionUnitId: data.optionUnitId ?? null,
|
|
3761
|
-
availabilitySlotId: data.availabilitySlotId ?? null,
|
|
3762
|
-
});
|
|
3763
|
-
const startsAtInput = data.startsAt !== undefined ? toTimestamp(data.startsAt) : enrichment.startsAt;
|
|
3764
|
-
const endsAtInput = data.endsAt !== undefined ? toTimestamp(data.endsAt) : enrichment.endsAt;
|
|
3765
|
-
const serviceDateInput = data.serviceDate !== undefined ? data.serviceDate : enrichment.serviceDate;
|
|
3766
|
-
const [row] = await tx
|
|
3767
|
-
.insert(bookingItems)
|
|
3768
|
-
.values({
|
|
3769
|
-
bookingId,
|
|
3770
|
-
title: data.title,
|
|
3771
|
-
description: data.description ?? null,
|
|
3772
|
-
itemType: data.itemType,
|
|
3773
|
-
status: data.status,
|
|
3774
|
-
serviceDate: serviceDateInput ?? null,
|
|
3775
|
-
startsAt: startsAtInput,
|
|
3776
|
-
endsAt: endsAtInput,
|
|
3777
|
-
quantity: data.quantity,
|
|
3778
|
-
sellCurrency: data.sellCurrency ?? booking.sellCurrency,
|
|
3779
|
-
unitSellAmountCents: data.unitSellAmountCents ?? null,
|
|
3780
|
-
totalSellAmountCents: data.totalSellAmountCents ?? null,
|
|
3781
|
-
costCurrency: data.costCurrency ?? null,
|
|
3782
|
-
unitCostAmountCents: data.unitCostAmountCents ?? null,
|
|
3783
|
-
totalCostAmountCents: data.totalCostAmountCents ?? null,
|
|
3784
|
-
notes: data.notes ?? null,
|
|
3785
|
-
productId: data.productId ?? null,
|
|
3786
|
-
optionId: data.optionId ?? null,
|
|
3787
|
-
optionUnitId: data.optionUnitId ?? null,
|
|
3788
|
-
pricingCategoryId: data.pricingCategoryId ?? null,
|
|
3789
|
-
availabilitySlotId: data.availabilitySlotId ?? null,
|
|
3790
|
-
productNameSnapshot: data.productNameSnapshot ?? enrichment.productName ?? null,
|
|
3791
|
-
optionNameSnapshot: data.optionNameSnapshot ?? enrichment.optionName ?? null,
|
|
3792
|
-
unitNameSnapshot: data.unitNameSnapshot ?? enrichment.unitName ?? null,
|
|
3793
|
-
departureLabelSnapshot: data.departureLabelSnapshot ?? enrichment.departureLabel ?? null,
|
|
3794
|
-
sourceSnapshotId: data.sourceSnapshotId ?? null,
|
|
3795
|
-
sourceOfferId: data.sourceOfferId ?? null,
|
|
3796
|
-
metadata: data.metadata ?? null,
|
|
3797
|
-
})
|
|
3798
|
-
.returning();
|
|
3799
|
-
if (!row) {
|
|
3800
|
-
return null;
|
|
3801
|
-
}
|
|
3802
|
-
await tx.insert(bookingActivityLog).values({
|
|
3803
|
-
bookingId,
|
|
3804
|
-
actorId: userId ?? "system",
|
|
3805
|
-
activityType: "item_update",
|
|
3806
|
-
description: `Booking item "${data.title}" added`,
|
|
3807
|
-
metadata: { bookingItemId: row.id, itemType: data.itemType },
|
|
3808
|
-
});
|
|
3809
|
-
await bookingsService.recomputeBookingTotal(tx, bookingId);
|
|
3810
|
-
return row;
|
|
3811
|
-
});
|
|
3812
|
-
},
|
|
3813
|
-
async updateItem(db, itemId, data) {
|
|
3814
|
-
return db.transaction(async (tx) => {
|
|
3815
|
-
// Refresh snapshots only when the foreign IDs change. Existing
|
|
3816
|
-
// snapshots are the historical record — overwriting them when
|
|
3817
|
-
// the catalog gets renamed would defeat their purpose. Callers
|
|
3818
|
-
// that want a manual re-snapshot can pass the `*Snapshot` fields
|
|
3819
|
-
// explicitly.
|
|
3820
|
-
const refreshing = data.productId !== undefined ||
|
|
3821
|
-
data.optionId !== undefined ||
|
|
3822
|
-
data.optionUnitId !== undefined ||
|
|
3823
|
-
data.availabilitySlotId !== undefined;
|
|
3824
|
-
const snapshotUpdates = {};
|
|
3825
|
-
if (refreshing) {
|
|
3826
|
-
const [current] = await tx
|
|
3827
|
-
.select({
|
|
3828
|
-
productId: bookingItems.productId,
|
|
3829
|
-
optionId: bookingItems.optionId,
|
|
3830
|
-
optionUnitId: bookingItems.optionUnitId,
|
|
3831
|
-
availabilitySlotId: bookingItems.availabilitySlotId,
|
|
3832
|
-
})
|
|
3833
|
-
.from(bookingItems)
|
|
3834
|
-
.where(eq(bookingItems.id, itemId))
|
|
3835
|
-
.limit(1);
|
|
3836
|
-
if (current) {
|
|
3837
|
-
const enrichment = await resolveBookingItemSnapshot(tx, {
|
|
3838
|
-
productId: data.productId !== undefined ? (data.productId ?? null) : current.productId,
|
|
3839
|
-
optionId: data.optionId !== undefined ? (data.optionId ?? null) : current.optionId,
|
|
3840
|
-
optionUnitId: data.optionUnitId !== undefined ? (data.optionUnitId ?? null) : current.optionUnitId,
|
|
3841
|
-
availabilitySlotId: data.availabilitySlotId !== undefined
|
|
3842
|
-
? (data.availabilitySlotId ?? null)
|
|
3843
|
-
: current.availabilitySlotId,
|
|
3844
|
-
});
|
|
3845
|
-
if (data.productNameSnapshot !== undefined) {
|
|
3846
|
-
snapshotUpdates.productNameSnapshot = data.productNameSnapshot;
|
|
3847
|
-
}
|
|
3848
|
-
else if (enrichment.productName !== null) {
|
|
3849
|
-
snapshotUpdates.productNameSnapshot = enrichment.productName;
|
|
3850
|
-
}
|
|
3851
|
-
if (data.optionNameSnapshot !== undefined) {
|
|
3852
|
-
snapshotUpdates.optionNameSnapshot = data.optionNameSnapshot;
|
|
3853
|
-
}
|
|
3854
|
-
else if (enrichment.optionName !== null) {
|
|
3855
|
-
snapshotUpdates.optionNameSnapshot = enrichment.optionName;
|
|
3856
|
-
}
|
|
3857
|
-
if (data.unitNameSnapshot !== undefined) {
|
|
3858
|
-
snapshotUpdates.unitNameSnapshot = data.unitNameSnapshot;
|
|
3859
|
-
}
|
|
3860
|
-
else if (enrichment.unitName !== null) {
|
|
3861
|
-
snapshotUpdates.unitNameSnapshot = enrichment.unitName;
|
|
3862
|
-
}
|
|
3863
|
-
if (data.departureLabelSnapshot !== undefined) {
|
|
3864
|
-
snapshotUpdates.departureLabelSnapshot = data.departureLabelSnapshot;
|
|
3865
|
-
}
|
|
3866
|
-
else if (enrichment.departureLabel !== null) {
|
|
3867
|
-
snapshotUpdates.departureLabelSnapshot = enrichment.departureLabel;
|
|
3868
|
-
}
|
|
3869
|
-
// If the caller didn't override timing and a slot was
|
|
3870
|
-
// (re)assigned, mirror the slot's timing onto the item.
|
|
3871
|
-
if (data.availabilitySlotId !== undefined && enrichment.startsAt) {
|
|
3872
|
-
if (data.startsAt === undefined)
|
|
3873
|
-
snapshotUpdates.startsAt = enrichment.startsAt;
|
|
3874
|
-
if (data.endsAt === undefined)
|
|
3875
|
-
snapshotUpdates.endsAt = enrichment.endsAt;
|
|
3876
|
-
if (data.serviceDate === undefined)
|
|
3877
|
-
snapshotUpdates.serviceDate = enrichment.serviceDate;
|
|
3878
|
-
}
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
|
-
const [row] = await tx
|
|
3882
|
-
.update(bookingItems)
|
|
3883
|
-
.set({
|
|
3884
|
-
...data,
|
|
3885
|
-
...snapshotUpdates,
|
|
3886
|
-
startsAt: snapshotUpdates.startsAt !== undefined
|
|
3887
|
-
? snapshotUpdates.startsAt
|
|
3888
|
-
: data.startsAt === undefined
|
|
3889
|
-
? undefined
|
|
3890
|
-
: toTimestamp(data.startsAt),
|
|
3891
|
-
endsAt: snapshotUpdates.endsAt !== undefined
|
|
3892
|
-
? snapshotUpdates.endsAt
|
|
3893
|
-
: data.endsAt === undefined
|
|
3894
|
-
? undefined
|
|
3895
|
-
: toTimestamp(data.endsAt),
|
|
3896
|
-
updatedAt: new Date(),
|
|
3897
|
-
})
|
|
3898
|
-
.where(eq(bookingItems.id, itemId))
|
|
3899
|
-
.returning();
|
|
3900
|
-
if (!row)
|
|
3901
|
-
return null;
|
|
3902
|
-
await bookingsService.recomputeBookingTotal(tx, row.bookingId);
|
|
3903
|
-
return row;
|
|
3904
|
-
});
|
|
3905
|
-
},
|
|
3906
|
-
async deleteItem(db, itemId) {
|
|
3907
|
-
return db.transaction(async (tx) => {
|
|
3908
|
-
// Look up the parent booking BEFORE the delete so we can roll up
|
|
3909
|
-
// afterwards.
|
|
3910
|
-
const [item] = await tx
|
|
3911
|
-
.select({ bookingId: bookingItems.bookingId })
|
|
3912
|
-
.from(bookingItems)
|
|
3913
|
-
.where(eq(bookingItems.id, itemId))
|
|
3914
|
-
.limit(1);
|
|
3915
|
-
const [row] = await tx
|
|
3916
|
-
.delete(bookingItems)
|
|
3917
|
-
.where(eq(bookingItems.id, itemId))
|
|
3918
|
-
.returning({ id: bookingItems.id });
|
|
3919
|
-
if (item) {
|
|
3920
|
-
await bookingsService.recomputeBookingTotal(tx, item.bookingId);
|
|
3921
|
-
}
|
|
3922
|
-
return row ?? null;
|
|
3923
|
-
});
|
|
3924
|
-
},
|
|
3925
|
-
listItemParticipants(db, itemId) {
|
|
3926
|
-
return db
|
|
3927
|
-
.select()
|
|
3928
|
-
.from(bookingItemTravelers)
|
|
3929
|
-
.where(eq(bookingItemTravelers.bookingItemId, itemId))
|
|
3930
|
-
.orderBy(desc(bookingItemTravelers.isPrimary), asc(bookingItemTravelers.createdAt));
|
|
3931
|
-
},
|
|
3932
|
-
async addItemParticipant(db, itemId, data) {
|
|
3933
|
-
const [item] = await db
|
|
3934
|
-
.select({ id: bookingItems.id })
|
|
3935
|
-
.from(bookingItems)
|
|
3936
|
-
.where(eq(bookingItems.id, itemId))
|
|
3937
|
-
.limit(1);
|
|
3938
|
-
if (!item) {
|
|
3939
|
-
return null;
|
|
3940
|
-
}
|
|
3941
|
-
const [traveler] = await db
|
|
3942
|
-
.select({ id: bookingTravelers.id })
|
|
3943
|
-
.from(bookingTravelers)
|
|
3944
|
-
.where(eq(bookingTravelers.id, data.travelerId))
|
|
3945
|
-
.limit(1);
|
|
3946
|
-
if (!traveler) {
|
|
3947
|
-
return null;
|
|
3948
|
-
}
|
|
3949
|
-
if (data.isPrimary) {
|
|
3950
|
-
await db
|
|
3951
|
-
.update(bookingItemTravelers)
|
|
3952
|
-
.set({ isPrimary: false })
|
|
3953
|
-
.where(eq(bookingItemTravelers.bookingItemId, itemId));
|
|
3954
|
-
}
|
|
3955
|
-
const [row] = await db
|
|
3956
|
-
.insert(bookingItemTravelers)
|
|
3957
|
-
.values({
|
|
3958
|
-
bookingItemId: itemId,
|
|
3959
|
-
travelerId: data.travelerId,
|
|
3960
|
-
role: data.role,
|
|
3961
|
-
isPrimary: data.isPrimary ?? false,
|
|
3962
|
-
})
|
|
3963
|
-
.returning();
|
|
3964
|
-
return row;
|
|
3965
|
-
},
|
|
3966
|
-
async removeItemParticipant(db, linkId) {
|
|
3967
|
-
const [row] = await db
|
|
3968
|
-
.delete(bookingItemTravelers)
|
|
3969
|
-
.where(eq(bookingItemTravelers.id, linkId))
|
|
3970
|
-
.returning({ id: bookingItemTravelers.id });
|
|
3971
|
-
return row ?? null;
|
|
3972
|
-
},
|
|
3973
|
-
listSupplierStatuses(db, bookingId) {
|
|
3974
|
-
return db
|
|
3975
|
-
.select()
|
|
3976
|
-
.from(bookingSupplierStatuses)
|
|
3977
|
-
.where(eq(bookingSupplierStatuses.bookingId, bookingId))
|
|
3978
|
-
.orderBy(asc(bookingSupplierStatuses.createdAt));
|
|
3979
|
-
},
|
|
3980
|
-
async createSupplierStatus(db, bookingId, data, userId) {
|
|
3981
|
-
const [booking] = await db
|
|
3982
|
-
.select({ id: bookings.id })
|
|
3983
|
-
.from(bookings)
|
|
3984
|
-
.where(eq(bookings.id, bookingId))
|
|
3985
|
-
.limit(1);
|
|
3986
|
-
if (!booking) {
|
|
3987
|
-
return null;
|
|
3988
|
-
}
|
|
3989
|
-
const [row] = await db
|
|
3990
|
-
.insert(bookingSupplierStatuses)
|
|
3991
|
-
.values({
|
|
3992
|
-
bookingId,
|
|
3993
|
-
supplierServiceId: data.supplierServiceId ?? null,
|
|
3994
|
-
supplierId: data.supplierId ?? null,
|
|
3995
|
-
serviceName: data.serviceName,
|
|
3996
|
-
status: data.status ?? "pending",
|
|
3997
|
-
supplierReference: data.supplierReference ?? null,
|
|
3998
|
-
costCurrency: data.costCurrency,
|
|
3999
|
-
costAmountCents: data.costAmountCents,
|
|
4000
|
-
supplierInvoiceLineId: data.supplierInvoiceLineId ?? null,
|
|
4001
|
-
notes: data.notes ?? null,
|
|
4002
|
-
confirmedAt: data.status === "confirmed" ? new Date() : null,
|
|
4003
|
-
})
|
|
4004
|
-
.returning();
|
|
4005
|
-
await db.insert(bookingActivityLog).values({
|
|
4006
|
-
bookingId,
|
|
4007
|
-
actorId: userId ?? "system",
|
|
4008
|
-
activityType: "supplier_update",
|
|
4009
|
-
description: `Supplier status for "${data.serviceName}" added`,
|
|
4010
|
-
});
|
|
4011
|
-
return row ?? null;
|
|
4012
|
-
},
|
|
4013
|
-
async updateSupplierStatus(db, bookingId, statusId, data, userId) {
|
|
4014
|
-
const updateData = {
|
|
4015
|
-
...data,
|
|
4016
|
-
supplierServiceId: data.supplierServiceId ?? undefined,
|
|
4017
|
-
supplierReference: data.supplierReference ?? undefined,
|
|
4018
|
-
confirmedAt: data.confirmedAt !== undefined
|
|
4019
|
-
? toTimestamp(data.confirmedAt)
|
|
4020
|
-
: data.status === "confirmed"
|
|
4021
|
-
? new Date()
|
|
4022
|
-
: undefined,
|
|
4023
|
-
updatedAt: new Date(),
|
|
4024
|
-
};
|
|
4025
|
-
const [row] = await db
|
|
4026
|
-
.update(bookingSupplierStatuses)
|
|
4027
|
-
.set(updateData)
|
|
4028
|
-
.where(eq(bookingSupplierStatuses.id, statusId))
|
|
4029
|
-
.returning();
|
|
4030
|
-
if (!row) {
|
|
4031
|
-
return null;
|
|
4032
|
-
}
|
|
4033
|
-
if (data.status) {
|
|
4034
|
-
await db.insert(bookingActivityLog).values({
|
|
4035
|
-
bookingId,
|
|
4036
|
-
actorId: userId ?? "system",
|
|
4037
|
-
activityType: "supplier_update",
|
|
4038
|
-
description: `Supplier "${row.serviceName}" status updated to ${data.status}`,
|
|
4039
|
-
metadata: { supplierStatusId: statusId, newStatus: data.status },
|
|
4040
|
-
});
|
|
4041
|
-
}
|
|
4042
|
-
return row;
|
|
4043
|
-
},
|
|
4044
|
-
listFulfillments(db, bookingId) {
|
|
4045
|
-
return db
|
|
4046
|
-
.select()
|
|
4047
|
-
.from(bookingFulfillments)
|
|
4048
|
-
.where(eq(bookingFulfillments.bookingId, bookingId))
|
|
4049
|
-
.orderBy(desc(bookingFulfillments.createdAt));
|
|
4050
|
-
},
|
|
4051
|
-
async issueFulfillment(db, bookingId, data, userId) {
|
|
4052
|
-
const [booking] = await db
|
|
4053
|
-
.select({ id: bookings.id })
|
|
4054
|
-
.from(bookings)
|
|
4055
|
-
.where(eq(bookings.id, bookingId))
|
|
4056
|
-
.limit(1);
|
|
4057
|
-
if (!booking) {
|
|
4058
|
-
return null;
|
|
4059
|
-
}
|
|
4060
|
-
const scoped = await ensureBookingScopedLinks(db, bookingId, data);
|
|
4061
|
-
if (!scoped.ok) {
|
|
4062
|
-
return null;
|
|
4063
|
-
}
|
|
4064
|
-
const status = data.status ?? "issued";
|
|
4065
|
-
const issuedAt = data.issuedAt !== undefined
|
|
4066
|
-
? toTimestamp(data.issuedAt)
|
|
4067
|
-
: status === "issued" || status === "reissued"
|
|
4068
|
-
? new Date()
|
|
4069
|
-
: null;
|
|
4070
|
-
const revokedAt = data.revokedAt !== undefined
|
|
4071
|
-
? toTimestamp(data.revokedAt)
|
|
4072
|
-
: status === "revoked"
|
|
4073
|
-
? new Date()
|
|
4074
|
-
: null;
|
|
4075
|
-
const [row] = await db
|
|
4076
|
-
.insert(bookingFulfillments)
|
|
4077
|
-
.values({
|
|
4078
|
-
bookingId,
|
|
4079
|
-
bookingItemId: data.bookingItemId ?? null,
|
|
4080
|
-
travelerId: data.travelerId ?? null,
|
|
4081
|
-
fulfillmentType: data.fulfillmentType,
|
|
4082
|
-
deliveryChannel: data.deliveryChannel,
|
|
4083
|
-
status,
|
|
4084
|
-
artifactUrl: data.artifactUrl ?? null,
|
|
4085
|
-
payload: data.payload ?? null,
|
|
4086
|
-
issuedAt,
|
|
4087
|
-
revokedAt,
|
|
4088
|
-
})
|
|
4089
|
-
.returning();
|
|
4090
|
-
await db.insert(bookingActivityLog).values({
|
|
4091
|
-
bookingId,
|
|
4092
|
-
actorId: userId ?? "system",
|
|
4093
|
-
activityType: "fulfillment_issued",
|
|
4094
|
-
description: `Booking fulfillment issued as ${data.fulfillmentType}`,
|
|
4095
|
-
metadata: {
|
|
4096
|
-
fulfillmentId: row?.id ?? null,
|
|
4097
|
-
bookingItemId: data.bookingItemId ?? null,
|
|
4098
|
-
travelerId: data.travelerId ?? null,
|
|
4099
|
-
status,
|
|
4100
|
-
},
|
|
4101
|
-
});
|
|
4102
|
-
return row ?? null;
|
|
4103
|
-
},
|
|
4104
|
-
async updateFulfillment(db, bookingId, fulfillmentId, data, userId) {
|
|
4105
|
-
const [existing] = await db
|
|
4106
|
-
.select({ id: bookingFulfillments.id })
|
|
4107
|
-
.from(bookingFulfillments)
|
|
4108
|
-
.where(and(eq(bookingFulfillments.id, fulfillmentId), eq(bookingFulfillments.bookingId, bookingId)))
|
|
4109
|
-
.limit(1);
|
|
4110
|
-
if (!existing) {
|
|
4111
|
-
return null;
|
|
4112
|
-
}
|
|
4113
|
-
const scoped = await ensureBookingScopedLinks(db, bookingId, data);
|
|
4114
|
-
if (!scoped.ok) {
|
|
4115
|
-
return null;
|
|
4116
|
-
}
|
|
4117
|
-
const nextStatus = data.status;
|
|
4118
|
-
const [row] = await db
|
|
4119
|
-
.update(bookingFulfillments)
|
|
4120
|
-
.set({
|
|
4121
|
-
bookingItemId: data.bookingItemId === undefined ? undefined : (data.bookingItemId ?? null),
|
|
4122
|
-
travelerId: data.travelerId === undefined ? undefined : (data.travelerId ?? null),
|
|
4123
|
-
fulfillmentType: data.fulfillmentType,
|
|
4124
|
-
deliveryChannel: data.deliveryChannel,
|
|
4125
|
-
status: nextStatus,
|
|
4126
|
-
artifactUrl: data.artifactUrl === undefined ? undefined : (data.artifactUrl ?? null),
|
|
4127
|
-
payload: data.payload === undefined ? undefined : (data.payload ?? null),
|
|
4128
|
-
issuedAt: data.issuedAt !== undefined
|
|
4129
|
-
? toTimestamp(data.issuedAt)
|
|
4130
|
-
: nextStatus === "issued" || nextStatus === "reissued"
|
|
4131
|
-
? new Date()
|
|
4132
|
-
: undefined,
|
|
4133
|
-
revokedAt: data.revokedAt !== undefined
|
|
4134
|
-
? toTimestamp(data.revokedAt)
|
|
4135
|
-
: nextStatus === "revoked"
|
|
4136
|
-
? new Date()
|
|
4137
|
-
: undefined,
|
|
4138
|
-
updatedAt: new Date(),
|
|
4139
|
-
})
|
|
4140
|
-
.where(eq(bookingFulfillments.id, fulfillmentId))
|
|
4141
|
-
.returning();
|
|
4142
|
-
if (row) {
|
|
4143
|
-
await db.insert(bookingActivityLog).values({
|
|
4144
|
-
bookingId,
|
|
4145
|
-
actorId: userId ?? "system",
|
|
4146
|
-
activityType: "fulfillment_updated",
|
|
4147
|
-
description: `Booking fulfillment ${fulfillmentId} updated`,
|
|
4148
|
-
metadata: {
|
|
4149
|
-
fulfillmentId,
|
|
4150
|
-
bookingItemId: row.bookingItemId,
|
|
4151
|
-
travelerId: row.travelerId,
|
|
4152
|
-
status: row.status,
|
|
4153
|
-
},
|
|
4154
|
-
});
|
|
4155
|
-
}
|
|
4156
|
-
return row ?? null;
|
|
4157
|
-
},
|
|
4158
|
-
listRedemptionEvents(db, bookingId) {
|
|
4159
|
-
return db
|
|
4160
|
-
.select()
|
|
4161
|
-
.from(bookingRedemptionEvents)
|
|
4162
|
-
.where(eq(bookingRedemptionEvents.bookingId, bookingId))
|
|
4163
|
-
.orderBy(desc(bookingRedemptionEvents.redeemedAt), desc(bookingRedemptionEvents.createdAt));
|
|
4164
|
-
},
|
|
4165
|
-
async recordRedemption(db, bookingId, data, userId) {
|
|
4166
|
-
return db.transaction(async (tx) => {
|
|
4167
|
-
const [booking] = await tx
|
|
4168
|
-
.select({
|
|
4169
|
-
id: bookings.id,
|
|
4170
|
-
redeemedAt: bookings.redeemedAt,
|
|
4171
|
-
})
|
|
4172
|
-
.from(bookings)
|
|
4173
|
-
.where(eq(bookings.id, bookingId))
|
|
4174
|
-
.limit(1);
|
|
4175
|
-
if (!booking) {
|
|
4176
|
-
return null;
|
|
4177
|
-
}
|
|
4178
|
-
const scoped = await ensureBookingScopedLinks(tx, bookingId, data);
|
|
4179
|
-
if (!scoped.ok) {
|
|
4180
|
-
return null;
|
|
4181
|
-
}
|
|
4182
|
-
const redeemedAt = toTimestamp(data.redeemedAt) ?? new Date();
|
|
4183
|
-
const [event] = await tx
|
|
4184
|
-
.insert(bookingRedemptionEvents)
|
|
4185
|
-
.values({
|
|
4186
|
-
bookingId,
|
|
4187
|
-
bookingItemId: data.bookingItemId ?? null,
|
|
4188
|
-
travelerId: data.travelerId ?? null,
|
|
4189
|
-
redeemedAt,
|
|
4190
|
-
redeemedBy: data.redeemedBy ?? userId ?? null,
|
|
4191
|
-
location: data.location ?? null,
|
|
4192
|
-
method: data.method,
|
|
4193
|
-
metadata: data.metadata ?? null,
|
|
4194
|
-
})
|
|
4195
|
-
.returning();
|
|
4196
|
-
if (!booking.redeemedAt || booking.redeemedAt < redeemedAt) {
|
|
4197
|
-
await tx
|
|
4198
|
-
.update(bookings)
|
|
4199
|
-
.set({
|
|
4200
|
-
redeemedAt,
|
|
4201
|
-
updatedAt: new Date(),
|
|
4202
|
-
})
|
|
4203
|
-
.where(eq(bookings.id, bookingId));
|
|
4204
|
-
}
|
|
4205
|
-
if (data.bookingItemId) {
|
|
4206
|
-
await tx
|
|
4207
|
-
.update(bookingItems)
|
|
4208
|
-
.set({
|
|
4209
|
-
status: "fulfilled",
|
|
4210
|
-
updatedAt: new Date(),
|
|
4211
|
-
})
|
|
4212
|
-
.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"))));
|
|
4213
|
-
await tx
|
|
4214
|
-
.update(bookingAllocations)
|
|
4215
|
-
.set({
|
|
4216
|
-
status: "fulfilled",
|
|
4217
|
-
updatedAt: new Date(),
|
|
4218
|
-
})
|
|
4219
|
-
.where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, data.bookingItemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
|
|
4220
|
-
}
|
|
4221
|
-
await tx.insert(bookingActivityLog).values({
|
|
4222
|
-
bookingId,
|
|
4223
|
-
actorId: userId ?? "system",
|
|
4224
|
-
activityType: "redemption_recorded",
|
|
4225
|
-
description: "Booking redemption recorded",
|
|
4226
|
-
metadata: {
|
|
4227
|
-
redemptionEventId: event?.id ?? null,
|
|
4228
|
-
bookingItemId: data.bookingItemId ?? null,
|
|
4229
|
-
travelerId: data.travelerId ?? null,
|
|
4230
|
-
redeemedAt: redeemedAt.toISOString(),
|
|
4231
|
-
method: data.method,
|
|
4232
|
-
},
|
|
4233
|
-
});
|
|
4234
|
-
await syncTransactionOnBookingRedeemed(tx, bookingId);
|
|
4235
|
-
return event ?? null;
|
|
4236
|
-
});
|
|
4237
|
-
},
|
|
4238
|
-
listActivity(db, bookingId) {
|
|
4239
|
-
// Surface a hydrated actor display name + email so the activity
|
|
4240
|
-
// timeline can render "By {name}" / "By {email}" instead of the raw
|
|
4241
|
-
// user id. `actorId` may be null (system events) or reference a
|
|
4242
|
-
// deleted user, both of which leave the join columns null.
|
|
4243
|
-
return db
|
|
4244
|
-
.select({
|
|
4245
|
-
...getTableColumns(bookingActivityLog),
|
|
4246
|
-
actorName: authUser.name,
|
|
4247
|
-
actorEmail: authUser.email,
|
|
4248
|
-
})
|
|
4249
|
-
.from(bookingActivityLog)
|
|
4250
|
-
.leftJoin(authUser, eq(bookingActivityLog.actorId, authUser.id))
|
|
4251
|
-
.where(eq(bookingActivityLog.bookingId, bookingId))
|
|
4252
|
-
.orderBy(desc(bookingActivityLog.createdAt));
|
|
4253
|
-
},
|
|
4254
|
-
listNotes(db, bookingId) {
|
|
4255
|
-
return db
|
|
4256
|
-
.select({
|
|
4257
|
-
...getTableColumns(bookingNotes),
|
|
4258
|
-
authorName: authUser.name,
|
|
4259
|
-
authorEmail: authUser.email,
|
|
4260
|
-
})
|
|
4261
|
-
.from(bookingNotes)
|
|
4262
|
-
.leftJoin(authUser, eq(bookingNotes.authorId, authUser.id))
|
|
4263
|
-
.where(eq(bookingNotes.bookingId, bookingId))
|
|
4264
|
-
.orderBy(bookingNotes.createdAt);
|
|
4265
|
-
},
|
|
4266
|
-
async createNote(db, bookingId, userId, data) {
|
|
4267
|
-
const [booking] = await db
|
|
4268
|
-
.select({ id: bookings.id })
|
|
4269
|
-
.from(bookings)
|
|
4270
|
-
.where(eq(bookings.id, bookingId))
|
|
4271
|
-
.limit(1);
|
|
4272
|
-
if (!booking) {
|
|
4273
|
-
return null;
|
|
4274
|
-
}
|
|
4275
|
-
const [row] = await db
|
|
4276
|
-
.insert(bookingNotes)
|
|
4277
|
-
.values({
|
|
4278
|
-
bookingId,
|
|
4279
|
-
authorId: userId,
|
|
4280
|
-
content: data.content,
|
|
4281
|
-
})
|
|
4282
|
-
.returning();
|
|
4283
|
-
await db.insert(bookingActivityLog).values({
|
|
4284
|
-
bookingId,
|
|
4285
|
-
actorId: userId,
|
|
4286
|
-
activityType: "note_added",
|
|
4287
|
-
description: "Note added",
|
|
4288
|
-
});
|
|
4289
|
-
return row;
|
|
4290
|
-
},
|
|
4291
|
-
async updateNote(db, bookingId, noteId, data) {
|
|
4292
|
-
// Scope the update to (booking, note) so a stale / cross-booking
|
|
4293
|
-
// note id can't mutate another booking's note while the route
|
|
4294
|
-
// records an audit entry under the wrong `bookingId`. Returns null
|
|
4295
|
-
// → route responds 404.
|
|
4296
|
-
const [row] = await db
|
|
4297
|
-
.update(bookingNotes)
|
|
4298
|
-
.set({ content: data.content })
|
|
4299
|
-
.where(and(eq(bookingNotes.id, noteId), eq(bookingNotes.bookingId, bookingId)))
|
|
4300
|
-
.returning();
|
|
4301
|
-
return row ?? null;
|
|
4302
|
-
},
|
|
4303
|
-
async deleteNote(db, bookingId, noteId) {
|
|
4304
|
-
// Same scoping as `updateNote` — guards against deleting a note
|
|
4305
|
-
// that belongs to a different booking when the audit entry would
|
|
4306
|
-
// get filed under the route's `:id`.
|
|
4307
|
-
const [row] = await db
|
|
4308
|
-
.delete(bookingNotes)
|
|
4309
|
-
.where(and(eq(bookingNotes.id, noteId), eq(bookingNotes.bookingId, bookingId)))
|
|
4310
|
-
.returning({ id: bookingNotes.id });
|
|
4311
|
-
return row ?? null;
|
|
4312
|
-
},
|
|
4313
|
-
listDocuments(db, bookingId) {
|
|
4314
|
-
return db
|
|
4315
|
-
.select()
|
|
4316
|
-
.from(bookingDocuments)
|
|
4317
|
-
.where(eq(bookingDocuments.bookingId, bookingId))
|
|
4318
|
-
.orderBy(bookingDocuments.createdAt);
|
|
4319
|
-
},
|
|
4320
|
-
async createDocument(db, bookingId, data) {
|
|
4321
|
-
const [booking] = await db
|
|
4322
|
-
.select({ id: bookings.id })
|
|
4323
|
-
.from(bookings)
|
|
4324
|
-
.where(eq(bookings.id, bookingId))
|
|
4325
|
-
.limit(1);
|
|
4326
|
-
if (!booking) {
|
|
4327
|
-
return null;
|
|
4328
|
-
}
|
|
4329
|
-
const [row] = await db
|
|
4330
|
-
.insert(bookingDocuments)
|
|
4331
|
-
.values({
|
|
4332
|
-
bookingId,
|
|
4333
|
-
travelerId: data.travelerId ?? null,
|
|
4334
|
-
type: data.type,
|
|
4335
|
-
fileName: data.fileName,
|
|
4336
|
-
fileUrl: data.fileUrl,
|
|
4337
|
-
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
|
|
4338
|
-
notes: data.notes ?? null,
|
|
4339
|
-
})
|
|
4340
|
-
.returning();
|
|
4341
|
-
return row;
|
|
4342
|
-
},
|
|
4343
|
-
async deleteDocument(db, documentId) {
|
|
4344
|
-
const [row] = await db
|
|
4345
|
-
.delete(bookingDocuments)
|
|
4346
|
-
.where(eq(bookingDocuments.id, documentId))
|
|
4347
|
-
.returning({ id: bookingDocuments.id });
|
|
4348
|
-
return row ?? null;
|
|
4349
|
-
},
|
|
4350
|
-
};
|
|
1
|
+
export * from "./service-core.js";
|