@voyantjs/bookings 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +15 -0
  2. package/dist/index.d.ts +6 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +7 -2
  5. package/dist/pricing-ref.d.ts +692 -0
  6. package/dist/pricing-ref.d.ts.map +1 -0
  7. package/dist/pricing-ref.js +49 -0
  8. package/dist/products-ref.d.ts +170 -0
  9. package/dist/products-ref.d.ts.map +1 -1
  10. package/dist/products-ref.js +10 -0
  11. package/dist/routes-public.d.ts +580 -0
  12. package/dist/routes-public.d.ts.map +1 -0
  13. package/dist/routes-public.js +105 -0
  14. package/dist/routes-shared.d.ts +66 -0
  15. package/dist/routes-shared.d.ts.map +1 -0
  16. package/dist/routes-shared.js +10 -0
  17. package/dist/routes.d.ts +3 -43
  18. package/dist/routes.d.ts.map +1 -1
  19. package/dist/routes.js +1 -7
  20. package/dist/schema-core.d.ts +1229 -0
  21. package/dist/schema-core.d.ts.map +1 -0
  22. package/dist/schema-core.js +81 -0
  23. package/dist/schema-items.d.ts +1278 -0
  24. package/dist/schema-items.d.ts.map +1 -0
  25. package/dist/schema-items.js +130 -0
  26. package/dist/schema-operations.d.ts +766 -0
  27. package/dist/schema-operations.d.ts.map +1 -0
  28. package/dist/schema-operations.js +78 -0
  29. package/dist/schema-relations.d.ts +62 -0
  30. package/dist/schema-relations.d.ts.map +1 -0
  31. package/dist/schema-relations.js +102 -0
  32. package/dist/schema-shared.d.ts +19 -0
  33. package/dist/schema-shared.d.ts.map +1 -0
  34. package/dist/schema-shared.js +137 -0
  35. package/dist/schema.d.ts +5 -3179
  36. package/dist/schema.d.ts.map +1 -1
  37. package/dist/schema.js +5 -509
  38. package/dist/service-public.d.ts +709 -0
  39. package/dist/service-public.d.ts.map +1 -0
  40. package/dist/service-public.js +887 -0
  41. package/dist/validation-public.d.ts +901 -0
  42. package/dist/validation-public.d.ts.map +1 -0
  43. package/dist/validation-public.js +267 -0
  44. package/dist/validation-shared.d.ts +118 -0
  45. package/dist/validation-shared.d.ts.map +1 -0
  46. package/dist/validation-shared.js +102 -0
  47. package/dist/validation.d.ts +2 -0
  48. package/dist/validation.d.ts.map +1 -1
  49. package/dist/validation.js +3 -90
  50. package/package.json +14 -5
@@ -0,0 +1,887 @@
1
+ import { and, asc, desc, eq, inArray, or } from "drizzle-orm";
2
+ import { optionPriceRulesRef, optionUnitPriceRulesRef, optionUnitTiersRef, priceCatalogsRef, } from "./pricing-ref.js";
3
+ import { optionUnitsRef, productOptionsRef, productsRef } from "./products-ref.js";
4
+ import { bookingAllocations, bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingParticipants, bookingSessionStates, bookings, } from "./schema.js";
5
+ import { bookingsService } from "./service.js";
6
+ const travelerParticipantTypes = new Set(["traveler", "occupant"]);
7
+ const WIZARD_STATE_KEY = "wizard";
8
+ function normalizeDate(value) {
9
+ if (!value) {
10
+ return null;
11
+ }
12
+ if (value instanceof Date) {
13
+ return value.toISOString().slice(0, 10);
14
+ }
15
+ return value;
16
+ }
17
+ function normalizeDateTime(value) {
18
+ if (!value) {
19
+ return null;
20
+ }
21
+ return value instanceof Date ? value.toISOString() : value;
22
+ }
23
+ function countTravelerParticipants(participants) {
24
+ return participants.filter((participant) => travelerParticipantTypes.has(participant.participantType)).length;
25
+ }
26
+ function normalizeSessionState(bookingId, state) {
27
+ if (!state) {
28
+ return null;
29
+ }
30
+ return {
31
+ sessionId: bookingId,
32
+ stateKey: WIZARD_STATE_KEY,
33
+ currentStep: state.currentStep ?? null,
34
+ completedSteps: state.completedSteps ?? [],
35
+ payload: state.payload ?? {},
36
+ version: state.version,
37
+ createdAt: normalizeDateTime(state.createdAt),
38
+ updatedAt: normalizeDateTime(state.updatedAt),
39
+ };
40
+ }
41
+ async function getWizardSessionState(db, bookingId) {
42
+ const [state] = await db
43
+ .select()
44
+ .from(bookingSessionStates)
45
+ .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, WIZARD_STATE_KEY)))
46
+ .limit(1);
47
+ return normalizeSessionState(bookingId, state);
48
+ }
49
+ async function upsertWizardSessionState(db, bookingId, input) {
50
+ const [existing] = await db
51
+ .select()
52
+ .from(bookingSessionStates)
53
+ .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, WIZARD_STATE_KEY)))
54
+ .limit(1);
55
+ const payload = input.replacePayload
56
+ ? input.payload
57
+ : {
58
+ ...(existing?.payload ?? {}),
59
+ ...input.payload,
60
+ };
61
+ const completedSteps = input.completedSteps ?? existing?.completedSteps ?? [];
62
+ const currentStep = input.currentStep === undefined ? (existing?.currentStep ?? null) : (input.currentStep ?? null);
63
+ if (existing) {
64
+ const [updated] = await db
65
+ .update(bookingSessionStates)
66
+ .set({
67
+ currentStep,
68
+ completedSteps,
69
+ payload,
70
+ version: existing.version + 1,
71
+ updatedAt: new Date(),
72
+ })
73
+ .where(eq(bookingSessionStates.id, existing.id))
74
+ .returning();
75
+ return normalizeSessionState(bookingId, updated ?? existing);
76
+ }
77
+ const [created] = await db
78
+ .insert(bookingSessionStates)
79
+ .values({
80
+ bookingId,
81
+ stateKey: WIZARD_STATE_KEY,
82
+ currentStep,
83
+ completedSteps,
84
+ payload,
85
+ version: 1,
86
+ })
87
+ .returning();
88
+ return normalizeSessionState(bookingId, created);
89
+ }
90
+ function resolveTierAmount(tiers, quantity, fallbackAmount) {
91
+ const tier = tiers.find((candidate) => quantity >= candidate.minQuantity &&
92
+ (candidate.maxQuantity === null || quantity <= candidate.maxQuantity));
93
+ return tier?.sellAmountCents ?? fallbackAmount;
94
+ }
95
+ function computeLineTotal(pricingMode, unitSellAmountCents, quantity, fallbackAmount) {
96
+ switch (pricingMode) {
97
+ case "free":
98
+ case "included":
99
+ return 0;
100
+ case "on_request":
101
+ return null;
102
+ case "per_unit":
103
+ case "per_person":
104
+ return unitSellAmountCents === null ? null : unitSellAmountCents * quantity;
105
+ default:
106
+ return unitSellAmountCents ?? fallbackAmount;
107
+ }
108
+ }
109
+ async function generateBookingNumber(db) {
110
+ const now = new Date();
111
+ const y = now.getFullYear().toString().slice(-2);
112
+ const m = String(now.getMonth() + 1).padStart(2, "0");
113
+ for (let attempt = 0; attempt < 10; attempt += 1) {
114
+ const suffix = String(Math.floor(Math.random() * 900000) + 100000);
115
+ const bookingNumber = `BK-${y}${m}-${suffix}`;
116
+ const [existing] = await db
117
+ .select({ id: bookings.id })
118
+ .from(bookings)
119
+ .where(eq(bookings.bookingNumber, bookingNumber))
120
+ .limit(1);
121
+ if (!existing) {
122
+ return bookingNumber;
123
+ }
124
+ }
125
+ throw new Error("Unable to generate a unique booking number");
126
+ }
127
+ function buildUnitWarnings(unit, quantity, sessionPax) {
128
+ const warnings = [];
129
+ if (!unit) {
130
+ return warnings;
131
+ }
132
+ if (unit.minQuantity !== null && quantity < unit.minQuantity) {
133
+ warnings.push(`Selected quantity is below the minimum for ${unit.name}.`);
134
+ }
135
+ if (unit.maxQuantity !== null && quantity > unit.maxQuantity) {
136
+ warnings.push(`Selected quantity is above the maximum for ${unit.name}.`);
137
+ }
138
+ if (sessionPax && unit.occupancyMin !== null && quantity * unit.occupancyMin > sessionPax) {
139
+ warnings.push(`Selected ${unit.name} quantity exceeds the current traveler count.`);
140
+ }
141
+ if (sessionPax && unit.occupancyMax !== null && quantity * unit.occupancyMax < sessionPax) {
142
+ warnings.push(`Selected ${unit.name} quantity does not cover the current traveler count.`);
143
+ }
144
+ return warnings;
145
+ }
146
+ async function resolveSessionPricingSnapshot(db, productId, input) {
147
+ const [product] = await db
148
+ .select({
149
+ id: productsRef.id,
150
+ })
151
+ .from(productsRef)
152
+ .where(and(eq(productsRef.id, productId), eq(productsRef.status, "active"), eq(productsRef.activated, true), eq(productsRef.visibility, "public")))
153
+ .limit(1);
154
+ if (!product) {
155
+ return null;
156
+ }
157
+ const catalog = input.catalogId
158
+ ? await db
159
+ .select({
160
+ id: priceCatalogsRef.id,
161
+ currencyCode: priceCatalogsRef.currencyCode,
162
+ })
163
+ .from(priceCatalogsRef)
164
+ .where(and(eq(priceCatalogsRef.id, input.catalogId), eq(priceCatalogsRef.catalogType, "public"), eq(priceCatalogsRef.active, true)))
165
+ .limit(1)
166
+ .then((rows) => rows[0] ?? null)
167
+ : await db
168
+ .select({
169
+ id: priceCatalogsRef.id,
170
+ currencyCode: priceCatalogsRef.currencyCode,
171
+ })
172
+ .from(priceCatalogsRef)
173
+ .where(and(eq(priceCatalogsRef.catalogType, "public"), eq(priceCatalogsRef.active, true)))
174
+ .orderBy(desc(priceCatalogsRef.isDefault), asc(priceCatalogsRef.name))
175
+ .limit(1)
176
+ .then((rows) => rows[0] ?? null);
177
+ if (!catalog) {
178
+ return null;
179
+ }
180
+ const optionConditions = [
181
+ eq(productOptionsRef.productId, productId),
182
+ eq(productOptionsRef.status, "active"),
183
+ ];
184
+ if (input.optionId) {
185
+ optionConditions.push(eq(productOptionsRef.id, input.optionId));
186
+ }
187
+ const options = await db
188
+ .select({
189
+ id: productOptionsRef.id,
190
+ name: productOptionsRef.name,
191
+ isDefault: productOptionsRef.isDefault,
192
+ })
193
+ .from(productOptionsRef)
194
+ .where(and(...optionConditions))
195
+ .orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder));
196
+ if (options.length === 0) {
197
+ return null;
198
+ }
199
+ const optionIds = options.map((option) => option.id);
200
+ const [rules, unitPrices] = await Promise.all([
201
+ db
202
+ .select({
203
+ id: optionPriceRulesRef.id,
204
+ optionId: optionPriceRulesRef.optionId,
205
+ pricingMode: optionPriceRulesRef.pricingMode,
206
+ baseSellAmountCents: optionPriceRulesRef.baseSellAmountCents,
207
+ isDefault: optionPriceRulesRef.isDefault,
208
+ })
209
+ .from(optionPriceRulesRef)
210
+ .where(and(eq(optionPriceRulesRef.productId, productId), inArray(optionPriceRulesRef.optionId, optionIds), eq(optionPriceRulesRef.priceCatalogId, catalog.id), eq(optionPriceRulesRef.active, true)))
211
+ .orderBy(desc(optionPriceRulesRef.isDefault), asc(optionPriceRulesRef.name)),
212
+ db
213
+ .select({
214
+ id: optionUnitPriceRulesRef.id,
215
+ optionPriceRuleId: optionUnitPriceRulesRef.optionPriceRuleId,
216
+ unitId: optionUnitPriceRulesRef.unitId,
217
+ unitName: optionUnitsRef.name,
218
+ unitType: optionUnitsRef.unitType,
219
+ pricingCategoryId: optionUnitPriceRulesRef.pricingCategoryId,
220
+ pricingMode: optionUnitPriceRulesRef.pricingMode,
221
+ sellAmountCents: optionUnitPriceRulesRef.sellAmountCents,
222
+ minQuantity: optionUnitPriceRulesRef.minQuantity,
223
+ maxQuantity: optionUnitPriceRulesRef.maxQuantity,
224
+ })
225
+ .from(optionUnitPriceRulesRef)
226
+ .innerJoin(optionUnitsRef, eq(optionUnitsRef.id, optionUnitPriceRulesRef.unitId))
227
+ .where(and(inArray(optionUnitPriceRulesRef.optionId, optionIds), eq(optionUnitPriceRulesRef.active, true)))
228
+ .orderBy(asc(optionUnitPriceRulesRef.sortOrder), asc(optionUnitPriceRulesRef.createdAt)),
229
+ ]);
230
+ const tiers = unitPrices.length > 0
231
+ ? await db
232
+ .select({
233
+ id: optionUnitTiersRef.id,
234
+ optionUnitPriceRuleId: optionUnitTiersRef.optionUnitPriceRuleId,
235
+ minQuantity: optionUnitTiersRef.minQuantity,
236
+ maxQuantity: optionUnitTiersRef.maxQuantity,
237
+ sellAmountCents: optionUnitTiersRef.sellAmountCents,
238
+ sortOrder: optionUnitTiersRef.sortOrder,
239
+ })
240
+ .from(optionUnitTiersRef)
241
+ .where(and(inArray(optionUnitTiersRef.optionUnitPriceRuleId, unitPrices.map((row) => row.id)), eq(optionUnitTiersRef.active, true)))
242
+ .orderBy(asc(optionUnitTiersRef.sortOrder), asc(optionUnitTiersRef.minQuantity))
243
+ : [];
244
+ const tiersByUnitPriceId = new Map();
245
+ for (const tier of tiers) {
246
+ const existing = tiersByUnitPriceId.get(tier.optionUnitPriceRuleId) ?? [];
247
+ existing.push(tier);
248
+ tiersByUnitPriceId.set(tier.optionUnitPriceRuleId, existing);
249
+ }
250
+ return {
251
+ catalog: catalog,
252
+ options: options,
253
+ rules: rules,
254
+ unitPrices: unitPrices.map((row) => ({
255
+ ...row,
256
+ tiers: tiersByUnitPriceId.get(row.id) ?? [],
257
+ })),
258
+ };
259
+ }
260
+ async function buildSessionSnapshot(db, bookingId) {
261
+ const [booking, participants, items, allocations, itemParticipantLinks, state] = await Promise.all([
262
+ bookingsService.getBookingById(db, bookingId),
263
+ db
264
+ .select()
265
+ .from(bookingParticipants)
266
+ .where(eq(bookingParticipants.bookingId, bookingId))
267
+ .orderBy(asc(bookingParticipants.createdAt)),
268
+ db
269
+ .select()
270
+ .from(bookingItems)
271
+ .where(eq(bookingItems.bookingId, bookingId))
272
+ .orderBy(asc(bookingItems.createdAt)),
273
+ db
274
+ .select()
275
+ .from(bookingAllocations)
276
+ .where(eq(bookingAllocations.bookingId, bookingId))
277
+ .orderBy(asc(bookingAllocations.createdAt)),
278
+ db
279
+ .select({
280
+ id: bookingItemParticipants.id,
281
+ bookingItemId: bookingItemParticipants.bookingItemId,
282
+ participantId: bookingItemParticipants.participantId,
283
+ role: bookingItemParticipants.role,
284
+ isPrimary: bookingItemParticipants.isPrimary,
285
+ })
286
+ .from(bookingItemParticipants)
287
+ .innerJoin(bookingItems, eq(bookingItems.id, bookingItemParticipants.bookingItemId))
288
+ .where(eq(bookingItems.bookingId, bookingId))
289
+ .orderBy(asc(bookingItemParticipants.createdAt)),
290
+ getWizardSessionState(db, bookingId),
291
+ ]);
292
+ if (!booking) {
293
+ return null;
294
+ }
295
+ const itemLinksByItemId = new Map();
296
+ for (const link of itemParticipantLinks) {
297
+ const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
298
+ existing.push({
299
+ id: link.id,
300
+ participantId: link.participantId,
301
+ role: link.role,
302
+ isPrimary: link.isPrimary,
303
+ });
304
+ itemLinksByItemId.set(link.bookingItemId, existing);
305
+ }
306
+ const hasParticipants = participants.length > 0;
307
+ const hasTraveler = countTravelerParticipants(participants) > 0;
308
+ const hasPrimaryParticipant = participants.some((participant) => participant.isPrimary);
309
+ const hasItems = items.length > 0;
310
+ const hasAllocations = allocations.length > 0;
311
+ return {
312
+ sessionId: booking.id,
313
+ bookingNumber: booking.bookingNumber,
314
+ status: booking.status,
315
+ externalBookingRef: booking.externalBookingRef ?? null,
316
+ communicationLanguage: booking.communicationLanguage ?? null,
317
+ sellCurrency: booking.sellCurrency,
318
+ sellAmountCents: booking.sellAmountCents ?? null,
319
+ startDate: normalizeDate(booking.startDate),
320
+ endDate: normalizeDate(booking.endDate),
321
+ pax: booking.pax ?? null,
322
+ holdExpiresAt: normalizeDateTime(booking.holdExpiresAt),
323
+ confirmedAt: normalizeDateTime(booking.confirmedAt),
324
+ expiredAt: normalizeDateTime(booking.expiredAt),
325
+ cancelledAt: normalizeDateTime(booking.cancelledAt),
326
+ completedAt: normalizeDateTime(booking.completedAt),
327
+ participants: participants.map((participant) => ({
328
+ id: participant.id,
329
+ participantType: participant.participantType,
330
+ travelerCategory: participant.travelerCategory ?? null,
331
+ firstName: participant.firstName,
332
+ lastName: participant.lastName,
333
+ email: participant.email ?? null,
334
+ phone: participant.phone ?? null,
335
+ preferredLanguage: participant.preferredLanguage ?? null,
336
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
337
+ specialRequests: participant.specialRequests ?? null,
338
+ isPrimary: participant.isPrimary,
339
+ notes: participant.notes ?? null,
340
+ })),
341
+ items: items.map((item) => ({
342
+ id: item.id,
343
+ title: item.title,
344
+ description: item.description ?? null,
345
+ itemType: item.itemType,
346
+ status: item.status,
347
+ serviceDate: normalizeDate(item.serviceDate),
348
+ startsAt: normalizeDateTime(item.startsAt),
349
+ endsAt: normalizeDateTime(item.endsAt),
350
+ quantity: item.quantity,
351
+ sellCurrency: item.sellCurrency,
352
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
353
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
354
+ costCurrency: item.costCurrency ?? null,
355
+ unitCostAmountCents: item.unitCostAmountCents ?? null,
356
+ totalCostAmountCents: item.totalCostAmountCents ?? null,
357
+ notes: item.notes ?? null,
358
+ productId: item.productId ?? null,
359
+ optionId: item.optionId ?? null,
360
+ optionUnitId: item.optionUnitId ?? null,
361
+ pricingCategoryId: item.pricingCategoryId ?? null,
362
+ participantLinks: itemLinksByItemId.get(item.id) ?? [],
363
+ })),
364
+ allocations: allocations.map((allocation) => ({
365
+ id: allocation.id,
366
+ bookingItemId: allocation.bookingItemId ?? null,
367
+ productId: allocation.productId ?? null,
368
+ optionId: allocation.optionId ?? null,
369
+ optionUnitId: allocation.optionUnitId ?? null,
370
+ pricingCategoryId: allocation.pricingCategoryId ?? null,
371
+ availabilitySlotId: allocation.availabilitySlotId ?? null,
372
+ quantity: allocation.quantity,
373
+ allocationType: allocation.allocationType,
374
+ status: allocation.status,
375
+ holdExpiresAt: normalizeDateTime(allocation.holdExpiresAt),
376
+ confirmedAt: normalizeDateTime(allocation.confirmedAt),
377
+ releasedAt: normalizeDateTime(allocation.releasedAt),
378
+ })),
379
+ checklist: {
380
+ hasParticipants,
381
+ hasTraveler,
382
+ hasPrimaryParticipant,
383
+ hasItems,
384
+ hasAllocations,
385
+ readyForConfirmation: booking.status === "on_hold" &&
386
+ hasParticipants &&
387
+ hasTraveler &&
388
+ hasPrimaryParticipant &&
389
+ hasItems &&
390
+ hasAllocations,
391
+ },
392
+ state,
393
+ };
394
+ }
395
+ export const publicBookingsService = {
396
+ async createSession(db, input, userId) {
397
+ const travelerCount = countTravelerParticipants(input.participants);
398
+ const bookingNumber = await generateBookingNumber(db);
399
+ const result = await bookingsService.reserveBooking(db, {
400
+ bookingNumber,
401
+ sourceType: "direct",
402
+ externalBookingRef: input.externalBookingRef ?? null,
403
+ communicationLanguage: input.communicationLanguage ?? null,
404
+ sellCurrency: input.sellCurrency,
405
+ baseCurrency: input.baseCurrency ?? null,
406
+ sellAmountCents: input.sellAmountCents ?? null,
407
+ baseSellAmountCents: input.baseSellAmountCents ?? null,
408
+ costAmountCents: input.costAmountCents ?? null,
409
+ baseCostAmountCents: input.baseCostAmountCents ?? null,
410
+ marginPercent: input.marginPercent ?? null,
411
+ startDate: input.startDate ?? null,
412
+ endDate: input.endDate ?? null,
413
+ pax: input.pax ?? (travelerCount > 0 ? travelerCount : null),
414
+ holdMinutes: input.holdMinutes,
415
+ holdExpiresAt: input.holdExpiresAt ?? null,
416
+ items: input.items.map((item) => ({
417
+ ...item,
418
+ sellCurrency: item.sellCurrency ?? input.sellCurrency,
419
+ costCurrency: item.costCurrency ?? null,
420
+ description: item.description ?? null,
421
+ notes: item.notes ?? null,
422
+ productId: item.productId ?? null,
423
+ optionId: item.optionId ?? null,
424
+ optionUnitId: item.optionUnitId ?? null,
425
+ pricingCategoryId: item.pricingCategoryId ?? null,
426
+ sourceSnapshotId: item.sourceSnapshotId ?? null,
427
+ sourceOfferId: null,
428
+ metadata: item.metadata ?? null,
429
+ })),
430
+ internalNotes: null,
431
+ }, userId);
432
+ if (!("booking" in result) || !result.booking) {
433
+ return result;
434
+ }
435
+ for (const participant of input.participants) {
436
+ await bookingsService.createParticipant(db, result.booking.id, {
437
+ participantType: participant.participantType,
438
+ travelerCategory: participant.travelerCategory ?? null,
439
+ firstName: participant.firstName,
440
+ lastName: participant.lastName,
441
+ email: participant.email ?? null,
442
+ phone: participant.phone ?? null,
443
+ preferredLanguage: participant.preferredLanguage ?? null,
444
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
445
+ specialRequests: participant.specialRequests ?? null,
446
+ isPrimary: participant.isPrimary,
447
+ notes: participant.notes ?? null,
448
+ personId: null,
449
+ }, userId);
450
+ }
451
+ const session = await buildSessionSnapshot(db, result.booking.id);
452
+ return session ? { status: "ok", session } : { status: "not_found" };
453
+ },
454
+ getSessionById(db, bookingId) {
455
+ return buildSessionSnapshot(db, bookingId);
456
+ },
457
+ async getSessionState(db, bookingId) {
458
+ const booking = await bookingsService.getBookingById(db, bookingId);
459
+ if (!booking) {
460
+ return null;
461
+ }
462
+ return getWizardSessionState(db, bookingId);
463
+ },
464
+ async updateSessionState(db, bookingId, input) {
465
+ const booking = await bookingsService.getBookingById(db, bookingId);
466
+ if (!booking) {
467
+ return { status: "not_found" };
468
+ }
469
+ const state = await upsertWizardSessionState(db, bookingId, input);
470
+ return { status: "ok", state };
471
+ },
472
+ async updateSession(db, bookingId, input, userId) {
473
+ const booking = await bookingsService.getBookingById(db, bookingId);
474
+ if (!booking) {
475
+ return { status: "not_found" };
476
+ }
477
+ if (input.externalBookingRef !== undefined ||
478
+ input.communicationLanguage !== undefined ||
479
+ input.pax !== undefined) {
480
+ await bookingsService.updateBooking(db, bookingId, {
481
+ externalBookingRef: input.externalBookingRef,
482
+ communicationLanguage: input.communicationLanguage,
483
+ pax: input.pax,
484
+ });
485
+ }
486
+ for (const participantId of input.removedParticipantIds) {
487
+ const participant = await bookingsService.getParticipantById(db, bookingId, participantId);
488
+ if (participant) {
489
+ await bookingsService.deleteParticipant(db, participant.id);
490
+ }
491
+ }
492
+ if (input.participants) {
493
+ for (const participant of input.participants) {
494
+ if (participant.id) {
495
+ const existing = await bookingsService.getParticipantById(db, bookingId, participant.id);
496
+ if (!existing) {
497
+ return { status: "participant_not_found" };
498
+ }
499
+ await bookingsService.updateParticipant(db, participant.id, {
500
+ participantType: participant.participantType,
501
+ travelerCategory: participant.travelerCategory ?? null,
502
+ firstName: participant.firstName,
503
+ lastName: participant.lastName,
504
+ email: participant.email ?? null,
505
+ phone: participant.phone ?? null,
506
+ preferredLanguage: participant.preferredLanguage ?? null,
507
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
508
+ specialRequests: participant.specialRequests ?? null,
509
+ isPrimary: participant.isPrimary,
510
+ notes: participant.notes ?? null,
511
+ });
512
+ continue;
513
+ }
514
+ await bookingsService.createParticipant(db, bookingId, {
515
+ participantType: participant.participantType,
516
+ travelerCategory: participant.travelerCategory ?? null,
517
+ firstName: participant.firstName,
518
+ lastName: participant.lastName,
519
+ email: participant.email ?? null,
520
+ phone: participant.phone ?? null,
521
+ preferredLanguage: participant.preferredLanguage ?? null,
522
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
523
+ specialRequests: participant.specialRequests ?? null,
524
+ isPrimary: participant.isPrimary,
525
+ notes: participant.notes ?? null,
526
+ personId: null,
527
+ }, userId);
528
+ }
529
+ }
530
+ if (input.holdMinutes !== undefined || input.holdExpiresAt !== undefined) {
531
+ const holdResult = await bookingsService.extendBookingHold(db, bookingId, {
532
+ holdMinutes: input.holdMinutes,
533
+ holdExpiresAt: input.holdExpiresAt,
534
+ }, userId);
535
+ if (holdResult.status !== "ok") {
536
+ return holdResult;
537
+ }
538
+ }
539
+ if (input.pax === undefined && (input.participants || input.removedParticipantIds.length > 0)) {
540
+ const participants = await db
541
+ .select({ participantType: bookingParticipants.participantType })
542
+ .from(bookingParticipants)
543
+ .where(eq(bookingParticipants.bookingId, bookingId));
544
+ const travelerCount = countTravelerParticipants(participants);
545
+ await bookingsService.updateBooking(db, bookingId, {
546
+ pax: travelerCount > 0 ? travelerCount : null,
547
+ });
548
+ }
549
+ const session = await buildSessionSnapshot(db, bookingId);
550
+ return session ? { status: "ok", session } : { status: "not_found" };
551
+ },
552
+ async repriceSession(db, bookingId, input) {
553
+ const [booking, items] = await Promise.all([
554
+ bookingsService.getBookingById(db, bookingId),
555
+ db
556
+ .select()
557
+ .from(bookingItems)
558
+ .where(eq(bookingItems.bookingId, bookingId))
559
+ .orderBy(asc(bookingItems.createdAt)),
560
+ ]);
561
+ if (!booking) {
562
+ return { status: "not_found" };
563
+ }
564
+ const selectedItemIds = input.selections.map((selection) => selection.itemId);
565
+ const itemById = new Map(items.map((item) => [item.id, item]));
566
+ for (const selection of input.selections) {
567
+ if (!itemById.has(selection.itemId)) {
568
+ return { status: "invalid_selection" };
569
+ }
570
+ }
571
+ const requestedUnitIds = Array.from(new Set(input.selections
572
+ .map((selection) => selection.optionUnitId)
573
+ .filter((value) => Boolean(value))));
574
+ const requestedUnits = requestedUnitIds.length > 0
575
+ ? await db.select().from(optionUnitsRef).where(inArray(optionUnitsRef.id, requestedUnitIds))
576
+ : [];
577
+ const requestedUnitById = new Map(requestedUnits.map((unit) => [unit.id, unit]));
578
+ const pricingWarnings = [];
579
+ const pricedItems = [];
580
+ let resolvedCatalogId = input.catalogId ?? null;
581
+ let resolvedCurrency = booking.sellCurrency;
582
+ for (const selection of input.selections) {
583
+ const item = itemById.get(selection.itemId);
584
+ if (!item?.productId) {
585
+ return { status: "invalid_selection" };
586
+ }
587
+ const optionId = selection.optionId === undefined
588
+ ? (item.optionId ?? undefined)
589
+ : (selection.optionId ?? undefined);
590
+ const quantity = selection.quantity ?? item.quantity;
591
+ const pricingCategoryId = selection.pricingCategoryId === undefined
592
+ ? (item.pricingCategoryId ?? null)
593
+ : (selection.pricingCategoryId ?? null);
594
+ const selectedUnitId = selection.optionUnitId === undefined
595
+ ? (item.optionUnitId ?? null)
596
+ : (selection.optionUnitId ?? null);
597
+ const snapshot = await resolveSessionPricingSnapshot(db, item.productId, {
598
+ catalogId: input.catalogId,
599
+ optionId,
600
+ });
601
+ if (!snapshot) {
602
+ return { status: "pricing_unavailable" };
603
+ }
604
+ resolvedCatalogId = snapshot.catalog.id;
605
+ resolvedCurrency = snapshot.catalog.currencyCode ?? booking.sellCurrency;
606
+ const option = snapshot.options.find((candidate) => candidate.id === optionId) ??
607
+ snapshot.options[0] ??
608
+ null;
609
+ if (!option) {
610
+ return { status: "pricing_unavailable" };
611
+ }
612
+ const rule = snapshot.rules.find((candidate) => candidate.optionId === option.id && candidate.isDefault) ??
613
+ snapshot.rules.find((candidate) => candidate.optionId === option.id) ??
614
+ null;
615
+ if (!rule) {
616
+ return { status: "pricing_unavailable" };
617
+ }
618
+ const ruleUnitPrices = snapshot.unitPrices.filter((candidate) => candidate.optionPriceRuleId === rule.id);
619
+ const unitPriceCandidates = ruleUnitPrices.filter((candidate) => {
620
+ if (selectedUnitId && candidate.unitId !== selectedUnitId) {
621
+ return false;
622
+ }
623
+ if (pricingCategoryId && candidate.pricingCategoryId !== pricingCategoryId) {
624
+ return false;
625
+ }
626
+ if (candidate.minQuantity !== null && quantity < candidate.minQuantity) {
627
+ return false;
628
+ }
629
+ if (candidate.maxQuantity !== null && quantity > candidate.maxQuantity) {
630
+ return false;
631
+ }
632
+ return true;
633
+ });
634
+ const fallbackUnitPrice = !pricingCategoryId && !selectedUnitId
635
+ ? (ruleUnitPrices.find((candidate) => candidate.pricingCategoryId === null &&
636
+ (candidate.minQuantity === null || quantity >= candidate.minQuantity) &&
637
+ (candidate.maxQuantity === null || quantity <= candidate.maxQuantity)) ?? null)
638
+ : null;
639
+ const unitPrice = unitPriceCandidates[0] ?? fallbackUnitPrice;
640
+ if ((selectedUnitId || ruleUnitPrices.length > 0) &&
641
+ !unitPrice &&
642
+ rule.pricingMode !== "per_booking") {
643
+ return { status: "pricing_unavailable" };
644
+ }
645
+ const unit = selectedUnitId ? (requestedUnitById.get(selectedUnitId) ?? null) : null;
646
+ const unitSellAmountCents = unitPrice
647
+ ? resolveTierAmount(unitPrice.tiers, quantity, unitPrice.sellAmountCents)
648
+ : rule.baseSellAmountCents;
649
+ const pricingMode = unitPrice?.pricingMode ?? rule.pricingMode;
650
+ const totalSellAmountCents = computeLineTotal(pricingMode, unitSellAmountCents, quantity, rule.baseSellAmountCents);
651
+ const warnings = buildUnitWarnings(unit, quantity, booking.pax ?? null);
652
+ if (selectedUnitId && !unit) {
653
+ warnings.push("Selected room/unit metadata is not available in the current catalog.");
654
+ }
655
+ if (pricingMode === "on_request") {
656
+ warnings.push("Selected option requires manual pricing confirmation.");
657
+ }
658
+ pricingWarnings.push(...warnings);
659
+ pricedItems.push({
660
+ itemId: item.id,
661
+ title: item.title,
662
+ productId: item.productId ?? null,
663
+ optionId: option.id,
664
+ optionUnitId: selectedUnitId,
665
+ optionUnitName: unit?.name ?? unitPrice?.unitName ?? null,
666
+ optionUnitType: unit?.unitType ?? unitPrice?.unitType ?? null,
667
+ pricingCategoryId,
668
+ quantity,
669
+ pricingMode,
670
+ unitSellAmountCents,
671
+ totalSellAmountCents,
672
+ warnings,
673
+ });
674
+ }
675
+ const totalSellAmountCents = items.reduce((total, item) => {
676
+ const repriced = pricedItems.find((candidate) => candidate.itemId === item.id);
677
+ return total + (repriced?.totalSellAmountCents ?? item.totalSellAmountCents ?? 0);
678
+ }, 0);
679
+ let session = null;
680
+ if (input.applyToSession) {
681
+ const activeAllocations = selectedItemIds.length > 0
682
+ ? await db
683
+ .select()
684
+ .from(bookingAllocations)
685
+ .where(and(eq(bookingAllocations.bookingId, bookingId), inArray(bookingAllocations.bookingItemId, selectedItemIds), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))))
686
+ : [];
687
+ const activeAllocationsByItemId = new Map();
688
+ for (const allocation of activeAllocations) {
689
+ const existing = activeAllocationsByItemId.get(allocation.bookingItemId) ?? [];
690
+ existing.push(allocation);
691
+ activeAllocationsByItemId.set(allocation.bookingItemId, existing);
692
+ }
693
+ const quantityChangedWithActiveAllocation = pricedItems.some((pricedItem) => {
694
+ const item = itemById.get(pricedItem.itemId);
695
+ return Boolean(item &&
696
+ item.quantity !== pricedItem.quantity &&
697
+ (activeAllocationsByItemId.get(pricedItem.itemId)?.length ?? 0) > 0);
698
+ });
699
+ if (quantityChangedWithActiveAllocation) {
700
+ return { status: "quantity_change_requires_reallocation" };
701
+ }
702
+ await db.transaction(async (tx) => {
703
+ for (const pricedItem of pricedItems) {
704
+ await tx
705
+ .update(bookingItems)
706
+ .set({
707
+ optionId: pricedItem.optionId,
708
+ optionUnitId: pricedItem.optionUnitId,
709
+ pricingCategoryId: pricedItem.pricingCategoryId,
710
+ quantity: pricedItem.quantity,
711
+ sellCurrency: resolvedCurrency,
712
+ unitSellAmountCents: pricedItem.unitSellAmountCents,
713
+ totalSellAmountCents: pricedItem.totalSellAmountCents,
714
+ updatedAt: new Date(),
715
+ })
716
+ .where(eq(bookingItems.id, pricedItem.itemId));
717
+ await tx
718
+ .update(bookingAllocations)
719
+ .set({
720
+ optionId: pricedItem.optionId,
721
+ optionUnitId: pricedItem.optionUnitId,
722
+ pricingCategoryId: pricedItem.pricingCategoryId,
723
+ updatedAt: new Date(),
724
+ })
725
+ .where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, pricedItem.itemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
726
+ }
727
+ await tx
728
+ .update(bookings)
729
+ .set({
730
+ sellCurrency: resolvedCurrency,
731
+ sellAmountCents: totalSellAmountCents,
732
+ updatedAt: new Date(),
733
+ })
734
+ .where(eq(bookings.id, bookingId));
735
+ });
736
+ session = await buildSessionSnapshot(db, bookingId);
737
+ }
738
+ return {
739
+ status: "ok",
740
+ pricing: {
741
+ sessionId: bookingId,
742
+ catalogId: resolvedCatalogId,
743
+ currencyCode: resolvedCurrency,
744
+ totalSellAmountCents,
745
+ items: pricedItems,
746
+ warnings: Array.from(new Set(pricingWarnings)),
747
+ appliedToSession: input.applyToSession,
748
+ },
749
+ session,
750
+ };
751
+ },
752
+ async confirmSession(db, bookingId, input, userId) {
753
+ const result = await bookingsService.confirmBooking(db, bookingId, input, userId);
754
+ if (result.status !== "ok") {
755
+ return result;
756
+ }
757
+ const session = await buildSessionSnapshot(db, bookingId);
758
+ return session ? { status: "ok", session } : { status: "not_found" };
759
+ },
760
+ async expireSession(db, bookingId, input, userId) {
761
+ const result = await bookingsService.expireBooking(db, bookingId, input, userId);
762
+ if (result.status !== "ok") {
763
+ return result;
764
+ }
765
+ const session = await buildSessionSnapshot(db, bookingId);
766
+ return session ? { status: "ok", session } : { status: "not_found" };
767
+ },
768
+ async getOverview(db, query) {
769
+ const [booking] = await db
770
+ .select()
771
+ .from(bookings)
772
+ .where(eq(bookings.bookingNumber, query.bookingNumber))
773
+ .limit(1);
774
+ if (!booking) {
775
+ return null;
776
+ }
777
+ const [participants, items, itemParticipantLinks, documents, fulfillments] = await Promise.all([
778
+ db
779
+ .select()
780
+ .from(bookingParticipants)
781
+ .where(eq(bookingParticipants.bookingId, booking.id))
782
+ .orderBy(asc(bookingParticipants.createdAt)),
783
+ db
784
+ .select()
785
+ .from(bookingItems)
786
+ .where(eq(bookingItems.bookingId, booking.id))
787
+ .orderBy(asc(bookingItems.createdAt)),
788
+ db
789
+ .select({
790
+ id: bookingItemParticipants.id,
791
+ bookingItemId: bookingItemParticipants.bookingItemId,
792
+ participantId: bookingItemParticipants.participantId,
793
+ role: bookingItemParticipants.role,
794
+ isPrimary: bookingItemParticipants.isPrimary,
795
+ })
796
+ .from(bookingItemParticipants)
797
+ .innerJoin(bookingItems, eq(bookingItems.id, bookingItemParticipants.bookingItemId))
798
+ .where(eq(bookingItems.bookingId, booking.id))
799
+ .orderBy(asc(bookingItemParticipants.createdAt)),
800
+ db
801
+ .select()
802
+ .from(bookingDocuments)
803
+ .where(eq(bookingDocuments.bookingId, booking.id))
804
+ .orderBy(asc(bookingDocuments.createdAt)),
805
+ db
806
+ .select()
807
+ .from(bookingFulfillments)
808
+ .where(eq(bookingFulfillments.bookingId, booking.id))
809
+ .orderBy(asc(bookingFulfillments.createdAt)),
810
+ ]);
811
+ const email = query.email.trim().toLowerCase();
812
+ const authorized = participants.some((participant) => participant.email?.toLowerCase() === email);
813
+ if (!authorized) {
814
+ return null;
815
+ }
816
+ const itemLinksByItemId = new Map();
817
+ for (const link of itemParticipantLinks) {
818
+ const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
819
+ existing.push({
820
+ id: link.id,
821
+ participantId: link.participantId,
822
+ role: link.role,
823
+ isPrimary: link.isPrimary,
824
+ });
825
+ itemLinksByItemId.set(link.bookingItemId, existing);
826
+ }
827
+ return {
828
+ bookingId: booking.id,
829
+ bookingNumber: booking.bookingNumber,
830
+ status: booking.status,
831
+ sellCurrency: booking.sellCurrency,
832
+ sellAmountCents: booking.sellAmountCents ?? null,
833
+ startDate: normalizeDate(booking.startDate),
834
+ endDate: normalizeDate(booking.endDate),
835
+ pax: booking.pax ?? null,
836
+ confirmedAt: normalizeDateTime(booking.confirmedAt),
837
+ cancelledAt: normalizeDateTime(booking.cancelledAt),
838
+ completedAt: normalizeDateTime(booking.completedAt),
839
+ participants: participants.map((participant) => ({
840
+ id: participant.id,
841
+ participantType: participant.participantType,
842
+ firstName: participant.firstName,
843
+ lastName: participant.lastName,
844
+ isPrimary: participant.isPrimary,
845
+ })),
846
+ items: items.map((item) => ({
847
+ id: item.id,
848
+ title: item.title,
849
+ description: item.description ?? null,
850
+ itemType: item.itemType,
851
+ status: item.status,
852
+ serviceDate: normalizeDate(item.serviceDate),
853
+ startsAt: normalizeDateTime(item.startsAt),
854
+ endsAt: normalizeDateTime(item.endsAt),
855
+ quantity: item.quantity,
856
+ sellCurrency: item.sellCurrency,
857
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
858
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
859
+ costCurrency: item.costCurrency ?? null,
860
+ unitCostAmountCents: item.unitCostAmountCents ?? null,
861
+ totalCostAmountCents: item.totalCostAmountCents ?? null,
862
+ notes: item.notes ?? null,
863
+ productId: item.productId ?? null,
864
+ optionId: item.optionId ?? null,
865
+ optionUnitId: item.optionUnitId ?? null,
866
+ pricingCategoryId: item.pricingCategoryId ?? null,
867
+ participantLinks: itemLinksByItemId.get(item.id) ?? [],
868
+ })),
869
+ documents: documents.map((document) => ({
870
+ id: document.id,
871
+ participantId: document.participantId ?? null,
872
+ type: document.type,
873
+ fileName: document.fileName,
874
+ fileUrl: document.fileUrl,
875
+ })),
876
+ fulfillments: fulfillments.map((fulfillment) => ({
877
+ id: fulfillment.id,
878
+ bookingItemId: fulfillment.bookingItemId ?? null,
879
+ participantId: fulfillment.participantId ?? null,
880
+ fulfillmentType: fulfillment.fulfillmentType,
881
+ deliveryChannel: fulfillment.deliveryChannel,
882
+ status: fulfillment.status,
883
+ artifactUrl: fulfillment.artifactUrl ?? null,
884
+ })),
885
+ };
886
+ },
887
+ };