@voyantjs/octo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,636 @@
1
+ import { availabilitySlots, availabilityStartTimes } from "@voyantjs/availability/schema";
2
+ import { bookingsService } from "@voyantjs/bookings";
3
+ import { productsService } from "@voyantjs/products";
4
+ import { offers, orders } from "@voyantjs/transactions/schema";
5
+ import { bookingAllocations, bookingFulfillments, bookingItemParticipants, bookingItems, bookingParticipants, bookingRedemptionEvents, bookingSupplierStatuses, bookings, } from "@voyantjs/bookings/schema";
6
+ import { optionUnits, productCapabilities, productDeliveryFormats, productFaqs, productFeatures, productLocations, productOptions, products, } from "@voyantjs/products/schema";
7
+ import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm";
8
+ import { bookingTransactionDetailsRef } from "./transactions-ref.js";
9
+ function toIsoString(value) {
10
+ return value ? value.toISOString() : null;
11
+ }
12
+ function formatLocalDateTime(value, timeZone) {
13
+ const parts = new Intl.DateTimeFormat("en-GB", {
14
+ timeZone,
15
+ hour12: false,
16
+ year: "numeric",
17
+ month: "2-digit",
18
+ day: "2-digit",
19
+ hour: "2-digit",
20
+ minute: "2-digit",
21
+ second: "2-digit",
22
+ }).formatToParts(value);
23
+ const lookup = Object.fromEntries(parts.map((part) => [part.type, part.value]));
24
+ return `${lookup.year}-${lookup.month}-${lookup.day}T${lookup.hour}:${lookup.minute}:${lookup.second}`;
25
+ }
26
+ function buildProjectedAvailability(slot, product) {
27
+ const timeZone = slot.timezone || product?.timezone || "UTC";
28
+ return {
29
+ id: slot.id,
30
+ productId: slot.productId,
31
+ optionId: slot.optionId,
32
+ localDateTimeStart: formatLocalDateTime(slot.startsAt, timeZone),
33
+ localDateTimeEnd: slot.endsAt ? formatLocalDateTime(slot.endsAt, timeZone) : null,
34
+ timeZone,
35
+ status: deriveOctoAvailabilityStatus(slot, product?.capacityMode),
36
+ vacancies: slot.unlimited ? null : slot.remainingPax,
37
+ capacity: slot.unlimited ? null : slot.initialPax,
38
+ };
39
+ }
40
+ export function inferOctoAvailabilityType(bookingMode) {
41
+ return bookingMode === "open" ? "OPENING_HOURS" : "START_TIME";
42
+ }
43
+ export function inferOctoUnitType(unit) {
44
+ const haystack = `${unit.code ?? ""} ${unit.name}`.toLowerCase();
45
+ if (haystack.includes("adult"))
46
+ return "ADULT";
47
+ if (haystack.includes("child"))
48
+ return "CHILD";
49
+ if (haystack.includes("youth") || haystack.includes("teen"))
50
+ return "YOUTH";
51
+ if (haystack.includes("infant") || haystack.includes("baby"))
52
+ return "INFANT";
53
+ if (haystack.includes("family"))
54
+ return "FAMILY";
55
+ if (haystack.includes("senior"))
56
+ return "SENIOR";
57
+ if (haystack.includes("student"))
58
+ return "STUDENT";
59
+ if (haystack.includes("military"))
60
+ return "MILITARY";
61
+ return unit.unitType === "person" ? "ADULT" : "OTHER";
62
+ }
63
+ export function deriveOctoAvailabilityStatus(slot, capacityMode) {
64
+ if (slot.status === "sold_out")
65
+ return "SOLD_OUT";
66
+ if (slot.status === "closed" || slot.status === "cancelled")
67
+ return "CLOSED";
68
+ if (capacityMode === "free_sale" || slot.unlimited)
69
+ return "FREESALE";
70
+ if (slot.initialPax !== null &&
71
+ slot.initialPax !== undefined &&
72
+ slot.remainingPax !== null &&
73
+ slot.remainingPax !== undefined) {
74
+ if (slot.remainingPax <= 0)
75
+ return "SOLD_OUT";
76
+ if (slot.initialPax > 0 && slot.remainingPax / slot.initialPax < 0.5)
77
+ return "LIMITED";
78
+ }
79
+ return "AVAILABLE";
80
+ }
81
+ export function mapBookingStatus(status) {
82
+ switch (status) {
83
+ case "on_hold":
84
+ return "ON_HOLD";
85
+ case "expired":
86
+ return "EXPIRED";
87
+ case "cancelled":
88
+ return "CANCELLED";
89
+ default:
90
+ return "CONFIRMED";
91
+ }
92
+ }
93
+ function mapUnit(unit) {
94
+ return {
95
+ id: unit.id,
96
+ name: unit.name,
97
+ code: unit.code,
98
+ type: inferOctoUnitType(unit),
99
+ restrictions: {
100
+ minAge: unit.minAge ?? undefined,
101
+ maxAge: unit.maxAge ?? undefined,
102
+ minQuantity: unit.minQuantity ?? undefined,
103
+ maxQuantity: unit.maxQuantity ?? undefined,
104
+ occupancyMin: unit.occupancyMin ?? undefined,
105
+ occupancyMax: unit.occupancyMax ?? undefined,
106
+ },
107
+ };
108
+ }
109
+ function buildProductContent({ features, faqs, locations, }) {
110
+ return {
111
+ highlights: features
112
+ .filter((feature) => feature.featureType === "highlight" || feature.featureType === "other")
113
+ .map((feature) => ({
114
+ id: feature.id,
115
+ title: feature.title,
116
+ description: feature.description,
117
+ })),
118
+ inclusions: features
119
+ .filter((feature) => feature.featureType === "inclusion")
120
+ .map((feature) => ({
121
+ id: feature.id,
122
+ title: feature.title,
123
+ description: feature.description,
124
+ })),
125
+ exclusions: features
126
+ .filter((feature) => feature.featureType === "exclusion")
127
+ .map((feature) => ({
128
+ id: feature.id,
129
+ title: feature.title,
130
+ description: feature.description,
131
+ })),
132
+ importantInformation: features
133
+ .filter((feature) => feature.featureType === "important_information")
134
+ .map((feature) => ({
135
+ id: feature.id,
136
+ title: feature.title,
137
+ description: feature.description,
138
+ })),
139
+ faqs: faqs.map((faq) => ({
140
+ id: faq.id,
141
+ question: faq.question,
142
+ answer: faq.answer,
143
+ })),
144
+ locations: locations.map((location) => ({
145
+ id: location.id,
146
+ type: location.locationType,
147
+ title: location.title,
148
+ address: location.address,
149
+ city: location.city,
150
+ countryCode: location.countryCode,
151
+ latitude: location.latitude,
152
+ longitude: location.longitude,
153
+ googlePlaceId: location.googlePlaceId,
154
+ applePlaceId: location.applePlaceId,
155
+ tripadvisorLocationId: location.tripadvisorLocationId,
156
+ })),
157
+ };
158
+ }
159
+ function pickOptionStartTimes(option, startTimes) {
160
+ const optionTimes = startTimes.filter((startTime) => startTime.optionId === option.id);
161
+ const sharedTimes = startTimes.filter((startTime) => startTime.optionId === null);
162
+ const source = optionTimes.length > 0 ? optionTimes : sharedTimes;
163
+ return source.map((startTime) => startTime.startTimeLocal);
164
+ }
165
+ function pickBookingContact(participants) {
166
+ const preferred = participants.find((participant) => participant.participantType === "booker") ??
167
+ participants.find((participant) => participant.participantType === "contact") ??
168
+ participants.find((participant) => participant.isPrimary) ??
169
+ participants[0];
170
+ if (!preferred)
171
+ return null;
172
+ return {
173
+ participantId: preferred.id,
174
+ firstName: preferred.firstName,
175
+ lastName: preferred.lastName,
176
+ email: preferred.email,
177
+ phone: preferred.phone,
178
+ language: preferred.preferredLanguage,
179
+ };
180
+ }
181
+ function pickPayloadString(payload, keys) {
182
+ if (!payload)
183
+ return null;
184
+ for (const key of keys) {
185
+ const value = payload[key];
186
+ if (typeof value === "string" && value.length > 0) {
187
+ return value;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+ export function mapBookingArtifact(fulfillment) {
193
+ const payload = fulfillment.payload ?? null;
194
+ const artifactUrl = fulfillment.artifactUrl;
195
+ const downloadUrl = pickPayloadString(payload, ["downloadUrl", "download_url", "url"]) ?? artifactUrl ?? null;
196
+ const pdfUrl = pickPayloadString(payload, ["pdfUrl", "pdf_url"]) ??
197
+ (fulfillment.fulfillmentType === "pdf" ? artifactUrl : null);
198
+ const qrCode = pickPayloadString(payload, ["qrCode", "qr_code"]) ??
199
+ (fulfillment.fulfillmentType === "qr_code"
200
+ ? pickPayloadString(payload, ["code", "voucherCode", "voucher_code"])
201
+ : null);
202
+ const barcode = pickPayloadString(payload, ["barcode", "barcodeValue", "barcode_value"]) ??
203
+ (fulfillment.fulfillmentType === "barcode"
204
+ ? pickPayloadString(payload, ["code", "voucherCode", "voucher_code"])
205
+ : null);
206
+ const voucherCode = pickPayloadString(payload, ["voucherCode", "voucher_code", "code"]);
207
+ return {
208
+ fulfillmentId: fulfillment.id,
209
+ bookingItemId: fulfillment.bookingItemId,
210
+ participantId: fulfillment.participantId,
211
+ type: fulfillment.fulfillmentType,
212
+ deliveryChannel: fulfillment.deliveryChannel,
213
+ status: fulfillment.status,
214
+ artifactUrl,
215
+ downloadUrl,
216
+ pdfUrl,
217
+ qrCode,
218
+ barcode,
219
+ voucherCode,
220
+ issuedAt: toIsoString(fulfillment.issuedAt),
221
+ revokedAt: toIsoString(fulfillment.revokedAt),
222
+ };
223
+ }
224
+ export const octoService = {
225
+ async getProjectedProductById(db, id) {
226
+ const [product] = await db.select().from(products).where(eq(products.id, id)).limit(1);
227
+ if (!product)
228
+ return null;
229
+ const [options, startTimes, capabilities, deliveryFormats, features, faqs, locations] = await Promise.all([
230
+ db
231
+ .select()
232
+ .from(productOptions)
233
+ .where(eq(productOptions.productId, product.id))
234
+ .orderBy(asc(productOptions.sortOrder), asc(productOptions.createdAt)),
235
+ db
236
+ .select()
237
+ .from(availabilityStartTimes)
238
+ .where(eq(availabilityStartTimes.productId, product.id))
239
+ .orderBy(asc(availabilityStartTimes.sortOrder), asc(availabilityStartTimes.createdAt)),
240
+ db
241
+ .select()
242
+ .from(productCapabilities)
243
+ .where(eq(productCapabilities.productId, product.id))
244
+ .orderBy(asc(productCapabilities.createdAt)),
245
+ db
246
+ .select()
247
+ .from(productDeliveryFormats)
248
+ .where(eq(productDeliveryFormats.productId, product.id))
249
+ .orderBy(asc(productDeliveryFormats.createdAt)),
250
+ db
251
+ .select()
252
+ .from(productFeatures)
253
+ .where(eq(productFeatures.productId, product.id))
254
+ .orderBy(asc(productFeatures.sortOrder), asc(productFeatures.createdAt)),
255
+ db
256
+ .select()
257
+ .from(productFaqs)
258
+ .where(eq(productFaqs.productId, product.id))
259
+ .orderBy(asc(productFaqs.sortOrder), asc(productFaqs.createdAt)),
260
+ db
261
+ .select()
262
+ .from(productLocations)
263
+ .where(eq(productLocations.productId, product.id))
264
+ .orderBy(asc(productLocations.sortOrder), asc(productLocations.createdAt)),
265
+ ]);
266
+ const optionIds = options.map((option) => option.id);
267
+ const units = optionIds.length > 0
268
+ ? await db
269
+ .select()
270
+ .from(optionUnits)
271
+ .where(inArray(optionUnits.optionId, optionIds))
272
+ .orderBy(asc(optionUnits.sortOrder), asc(optionUnits.createdAt))
273
+ : [];
274
+ return {
275
+ id: product.id,
276
+ name: product.name,
277
+ description: product.description,
278
+ timeZone: product.timezone,
279
+ availabilityType: inferOctoAvailabilityType(product.bookingMode),
280
+ allowFreesale: product.capacityMode === "free_sale",
281
+ instantConfirmation: capabilities.some((capability) => capability.capability === "instant_confirmation" && capability.enabled),
282
+ options: options.map((option) => ({
283
+ id: option.id,
284
+ name: option.name,
285
+ code: option.code,
286
+ default: option.isDefault,
287
+ availabilityLocalStartTimes: pickOptionStartTimes(option, startTimes),
288
+ units: units.filter((unit) => unit.optionId === option.id).map(mapUnit),
289
+ })),
290
+ content: buildProductContent({ features, faqs, locations }),
291
+ extensions: {
292
+ status: product.status,
293
+ visibility: product.visibility,
294
+ activated: product.activated,
295
+ facilityId: product.facilityId ?? null,
296
+ bookingMode: product.bookingMode,
297
+ capabilityCodes: capabilities
298
+ .filter((capability) => capability.enabled)
299
+ .map((capability) => capability.capability),
300
+ deliveryFormats: deliveryFormats.map((format) => format.format),
301
+ },
302
+ };
303
+ },
304
+ async getProjectedAvailabilityById(db, id) {
305
+ const [row] = await db.select().from(availabilitySlots).where(eq(availabilitySlots.id, id)).limit(1);
306
+ if (!row)
307
+ return null;
308
+ const [product] = await db
309
+ .select({ capacityMode: products.capacityMode, timezone: products.timezone })
310
+ .from(products)
311
+ .where(eq(products.id, row.productId))
312
+ .limit(1);
313
+ return buildProjectedAvailability(row, product);
314
+ },
315
+ async listProjectedAvailability(db, query) {
316
+ const conditions = [];
317
+ if (query.productId)
318
+ conditions.push(eq(availabilitySlots.productId, query.productId));
319
+ if (query.optionId)
320
+ conditions.push(eq(availabilitySlots.optionId, query.optionId));
321
+ if (query.localDateStart)
322
+ conditions.push(gte(availabilitySlots.dateLocal, query.localDateStart));
323
+ if (query.localDateEnd)
324
+ conditions.push(lte(availabilitySlots.dateLocal, query.localDateEnd));
325
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
326
+ const [rows, countResult] = await Promise.all([
327
+ db
328
+ .select()
329
+ .from(availabilitySlots)
330
+ .where(where)
331
+ .limit(query.limit)
332
+ .offset(query.offset)
333
+ .orderBy(asc(availabilitySlots.startsAt)),
334
+ db.select({ count: sql `count(*)::int` }).from(availabilitySlots).where(where),
335
+ ]);
336
+ const productIds = [...new Set(rows.map((row) => row.productId))];
337
+ const productRows = productIds.length > 0
338
+ ? await db
339
+ .select({
340
+ id: products.id,
341
+ capacityMode: products.capacityMode,
342
+ timezone: products.timezone,
343
+ })
344
+ .from(products)
345
+ .where(inArray(products.id, productIds))
346
+ : [];
347
+ const productsById = new Map(productRows.map((product) => [product.id, product]));
348
+ return {
349
+ data: rows.map((row) => buildProjectedAvailability(row, productsById.get(row.productId))),
350
+ total: countResult[0]?.count ?? 0,
351
+ limit: query.limit,
352
+ offset: query.offset,
353
+ };
354
+ },
355
+ async getProjectedAvailabilityCalendar(db, productId, query) {
356
+ const result = await this.listProjectedAvailability(db, {
357
+ productId,
358
+ optionId: query.optionId,
359
+ localDateStart: query.localDateStart,
360
+ localDateEnd: query.localDateEnd,
361
+ limit: 200,
362
+ offset: 0,
363
+ });
364
+ const days = new Map();
365
+ const rank = {
366
+ FREESALE: 5,
367
+ AVAILABLE: 4,
368
+ LIMITED: 3,
369
+ SOLD_OUT: 2,
370
+ CLOSED: 1,
371
+ };
372
+ for (const availability of result.data) {
373
+ const localDate = availability.localDateTimeStart.slice(0, 10);
374
+ const existing = days.get(localDate);
375
+ const nextStatus = !existing || rank[availability.status] > rank[existing.status]
376
+ ? availability.status
377
+ : existing.status;
378
+ days.set(localDate, {
379
+ localDate,
380
+ status: nextStatus,
381
+ vacancies: existing?.vacancies === null || availability.vacancies === null
382
+ ? null
383
+ : Math.max(existing?.vacancies ?? 0, availability.vacancies),
384
+ capacity: existing?.capacity === null || availability.capacity === null
385
+ ? null
386
+ : Math.max(existing?.capacity ?? 0, availability.capacity),
387
+ availabilityIds: [...(existing?.availabilityIds ?? []), availability.id],
388
+ });
389
+ }
390
+ return {
391
+ data: [...days.values()].sort((left, right) => left.localDate.localeCompare(right.localDate)),
392
+ total: days.size,
393
+ };
394
+ },
395
+ async listProjectedProducts(db, query) {
396
+ const result = await productsService.listProducts(db, query);
397
+ const data = await Promise.all(result.data.map(async (product) => this.getProjectedProductById(db, product.id)));
398
+ return {
399
+ data: data.filter((row) => Boolean(row)),
400
+ total: result.total,
401
+ limit: result.limit,
402
+ offset: result.offset,
403
+ };
404
+ },
405
+ async getProjectedBookingById(db, id) {
406
+ const [booking] = await db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
407
+ if (!booking)
408
+ return null;
409
+ const [participants, items, allocations, fulfillments, redemptions, supplierStatuses, transactionLink] = await Promise.all([
410
+ db
411
+ .select()
412
+ .from(bookingParticipants)
413
+ .where(eq(bookingParticipants.bookingId, booking.id))
414
+ .orderBy(asc(bookingParticipants.createdAt)),
415
+ db
416
+ .select()
417
+ .from(bookingItems)
418
+ .where(eq(bookingItems.bookingId, booking.id))
419
+ .orderBy(asc(bookingItems.createdAt)),
420
+ db
421
+ .select()
422
+ .from(bookingAllocations)
423
+ .where(eq(bookingAllocations.bookingId, booking.id))
424
+ .orderBy(asc(bookingAllocations.createdAt)),
425
+ db
426
+ .select()
427
+ .from(bookingFulfillments)
428
+ .where(eq(bookingFulfillments.bookingId, booking.id))
429
+ .orderBy(asc(bookingFulfillments.createdAt)),
430
+ db
431
+ .select()
432
+ .from(bookingRedemptionEvents)
433
+ .where(eq(bookingRedemptionEvents.bookingId, booking.id))
434
+ .orderBy(asc(bookingRedemptionEvents.redeemedAt), asc(bookingRedemptionEvents.createdAt)),
435
+ db
436
+ .select()
437
+ .from(bookingSupplierStatuses)
438
+ .where(eq(bookingSupplierStatuses.bookingId, booking.id))
439
+ .orderBy(asc(bookingSupplierStatuses.createdAt)),
440
+ db
441
+ .select()
442
+ .from(bookingTransactionDetailsRef)
443
+ .where(eq(bookingTransactionDetailsRef.bookingId, booking.id))
444
+ .limit(1)
445
+ .then((rows) => rows[0] ?? null),
446
+ ]);
447
+ const itemParticipants = items.length > 0
448
+ ? await db
449
+ .select()
450
+ .from(bookingItemParticipants)
451
+ .where(inArray(bookingItemParticipants.bookingItemId, items.map((item) => item.id)))
452
+ .orderBy(asc(bookingItemParticipants.createdAt))
453
+ : [];
454
+ const activeAllocation = allocations.find((allocation) => allocation.status === "confirmed") ??
455
+ allocations.find((allocation) => allocation.status === "held") ??
456
+ allocations[0];
457
+ const [offer, order] = await Promise.all([
458
+ transactionLink?.offerId
459
+ ? db
460
+ .select({ id: offers.id, offerNumber: offers.offerNumber })
461
+ .from(offers)
462
+ .where(eq(offers.id, transactionLink.offerId))
463
+ .limit(1)
464
+ .then((rows) => rows[0] ?? null)
465
+ : Promise.resolve(null),
466
+ transactionLink?.orderId
467
+ ? db
468
+ .select({ id: orders.id, orderNumber: orders.orderNumber })
469
+ .from(orders)
470
+ .where(eq(orders.id, transactionLink.orderId))
471
+ .limit(1)
472
+ .then((rows) => rows[0] ?? null)
473
+ : Promise.resolve(null),
474
+ ]);
475
+ return {
476
+ id: booking.id,
477
+ bookingNumber: booking.bookingNumber,
478
+ status: mapBookingStatus(booking.status),
479
+ availabilityId: activeAllocation?.availabilitySlotId ?? null,
480
+ contact: pickBookingContact(participants),
481
+ unitItems: items.map((item) => {
482
+ const itemAllocation = allocations.find((allocation) => allocation.bookingItemId === item.id) ?? null;
483
+ return {
484
+ bookingItemId: item.id,
485
+ title: item.title,
486
+ itemType: item.itemType,
487
+ status: item.status,
488
+ quantity: item.quantity,
489
+ productId: item.productId,
490
+ optionId: item.optionId,
491
+ unitId: item.optionUnitId,
492
+ pricingCategoryId: item.pricingCategoryId,
493
+ availabilityId: itemAllocation?.availabilitySlotId ?? null,
494
+ participantIds: itemParticipants
495
+ .filter((link) => link.bookingItemId === item.id)
496
+ .map((link) => link.participantId),
497
+ };
498
+ }),
499
+ fulfillments: fulfillments.map((fulfillment) => ({
500
+ id: fulfillment.id,
501
+ bookingItemId: fulfillment.bookingItemId,
502
+ participantId: fulfillment.participantId,
503
+ type: fulfillment.fulfillmentType,
504
+ deliveryChannel: fulfillment.deliveryChannel,
505
+ status: fulfillment.status,
506
+ artifactUrl: fulfillment.artifactUrl,
507
+ payload: fulfillment.payload ?? null,
508
+ issuedAt: toIsoString(fulfillment.issuedAt),
509
+ revokedAt: toIsoString(fulfillment.revokedAt),
510
+ })),
511
+ artifacts: fulfillments.map(mapBookingArtifact),
512
+ redemptions: redemptions.map((event) => ({
513
+ id: event.id,
514
+ bookingItemId: event.bookingItemId,
515
+ participantId: event.participantId,
516
+ redeemedAt: event.redeemedAt.toISOString(),
517
+ redeemedBy: event.redeemedBy,
518
+ location: event.location,
519
+ method: event.method,
520
+ metadata: event.metadata ?? null,
521
+ })),
522
+ references: {
523
+ resellerReference: booking.externalBookingRef,
524
+ offerId: offer?.id ?? transactionLink?.offerId ?? null,
525
+ offerNumber: offer?.offerNumber ?? null,
526
+ orderId: order?.id ?? transactionLink?.orderId ?? null,
527
+ orderNumber: order?.orderNumber ?? null,
528
+ supplierReferences: supplierStatuses.map((status) => ({
529
+ id: status.id,
530
+ supplierServiceId: status.supplierServiceId,
531
+ serviceName: status.serviceName,
532
+ status: status.status,
533
+ supplierReference: status.supplierReference,
534
+ confirmedAt: toIsoString(status.confirmedAt),
535
+ })),
536
+ },
537
+ holdExpiresAt: toIsoString(booking.holdExpiresAt),
538
+ confirmedAt: toIsoString(booking.confirmedAt),
539
+ cancelledAt: toIsoString(booking.cancelledAt),
540
+ expiredAt: toIsoString(booking.expiredAt),
541
+ utcRedeemedAt: toIsoString(booking.redeemedAt),
542
+ extensions: {
543
+ sourceType: booking.sourceType,
544
+ externalBookingRef: booking.externalBookingRef,
545
+ communicationLanguage: booking.communicationLanguage,
546
+ personId: booking.personId,
547
+ organizationId: booking.organizationId,
548
+ sellCurrency: booking.sellCurrency,
549
+ baseCurrency: booking.baseCurrency,
550
+ },
551
+ };
552
+ },
553
+ async listProjectedBookings(db, query) {
554
+ const result = await bookingsService.listBookings(db, query);
555
+ const data = await Promise.all(result.data.map(async (booking) => this.getProjectedBookingById(db, booking.id)));
556
+ return {
557
+ data: data.filter((row) => Boolean(row)),
558
+ total: result.total,
559
+ limit: result.limit,
560
+ offset: result.offset,
561
+ };
562
+ },
563
+ async reserveProjectedBooking(db, data, userId) {
564
+ const result = await bookingsService.reserveBooking(db, data, userId);
565
+ if (!("booking" in result) || !result.booking) {
566
+ return result;
567
+ }
568
+ const projected = await this.getProjectedBookingById(db, result.booking.id);
569
+ return { status: "ok", booking: projected };
570
+ },
571
+ async confirmProjectedBooking(db, id, data, userId) {
572
+ const result = await bookingsService.confirmBooking(db, id, data, userId);
573
+ if (!("booking" in result) || !result.booking) {
574
+ return result;
575
+ }
576
+ const projected = await this.getProjectedBookingById(db, result.booking.id);
577
+ return { status: "ok", booking: projected };
578
+ },
579
+ async extendProjectedBookingHold(db, id, data, userId) {
580
+ const result = await bookingsService.extendBookingHold(db, id, data, userId);
581
+ if (!("booking" in result) || !result.booking) {
582
+ return result;
583
+ }
584
+ const projected = await this.getProjectedBookingById(db, result.booking.id);
585
+ return { status: "ok", booking: projected };
586
+ },
587
+ async expireProjectedBooking(db, id, data, userId) {
588
+ const result = await bookingsService.expireBooking(db, id, data, userId);
589
+ if (!("booking" in result) || !result.booking) {
590
+ return result;
591
+ }
592
+ const projected = await this.getProjectedBookingById(db, result.booking.id);
593
+ return { status: "ok", booking: projected };
594
+ },
595
+ async cancelProjectedBooking(db, id, data, userId) {
596
+ const result = await bookingsService.cancelBooking(db, id, data, userId);
597
+ if (!("booking" in result) || !result.booking) {
598
+ return result;
599
+ }
600
+ const projected = await this.getProjectedBookingById(db, result.booking.id);
601
+ return { status: "ok", booking: projected };
602
+ },
603
+ async listProjectedRedemptions(db, bookingId) {
604
+ const events = await bookingsService.listRedemptionEvents(db, bookingId);
605
+ return events.map((event) => ({
606
+ id: event.id,
607
+ bookingItemId: event.bookingItemId,
608
+ participantId: event.participantId,
609
+ redeemedAt: event.redeemedAt.toISOString(),
610
+ redeemedBy: event.redeemedBy,
611
+ location: event.location,
612
+ method: event.method,
613
+ metadata: event.metadata ?? null,
614
+ }));
615
+ },
616
+ async recordProjectedRedemption(db, bookingId, data, userId) {
617
+ const event = await bookingsService.recordRedemption(db, bookingId, data, userId);
618
+ if (!event) {
619
+ return null;
620
+ }
621
+ const booking = await this.getProjectedBookingById(db, bookingId);
622
+ return {
623
+ event: {
624
+ id: event.id,
625
+ bookingItemId: event.bookingItemId,
626
+ participantId: event.participantId,
627
+ redeemedAt: event.redeemedAt.toISOString(),
628
+ redeemedBy: event.redeemedBy,
629
+ location: event.location,
630
+ method: event.method,
631
+ metadata: event.metadata ?? null,
632
+ },
633
+ booking,
634
+ };
635
+ },
636
+ };