@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.
- package/README.md +15 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/pricing-ref.d.ts +692 -0
- package/dist/pricing-ref.d.ts.map +1 -0
- package/dist/pricing-ref.js +49 -0
- package/dist/products-ref.d.ts +170 -0
- package/dist/products-ref.d.ts.map +1 -1
- package/dist/products-ref.js +10 -0
- package/dist/routes-public.d.ts +580 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +105 -0
- package/dist/routes-shared.d.ts +66 -0
- package/dist/routes-shared.d.ts.map +1 -0
- package/dist/routes-shared.js +10 -0
- package/dist/routes.d.ts +3 -43
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +1 -7
- package/dist/schema-core.d.ts +1229 -0
- package/dist/schema-core.d.ts.map +1 -0
- package/dist/schema-core.js +81 -0
- package/dist/schema-items.d.ts +1278 -0
- package/dist/schema-items.d.ts.map +1 -0
- package/dist/schema-items.js +130 -0
- package/dist/schema-operations.d.ts +766 -0
- package/dist/schema-operations.d.ts.map +1 -0
- package/dist/schema-operations.js +78 -0
- package/dist/schema-relations.d.ts +62 -0
- package/dist/schema-relations.d.ts.map +1 -0
- package/dist/schema-relations.js +102 -0
- package/dist/schema-shared.d.ts +19 -0
- package/dist/schema-shared.d.ts.map +1 -0
- package/dist/schema-shared.js +137 -0
- package/dist/schema.d.ts +5 -3179
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +5 -509
- package/dist/service-public.d.ts +709 -0
- package/dist/service-public.d.ts.map +1 -0
- package/dist/service-public.js +887 -0
- package/dist/validation-public.d.ts +901 -0
- package/dist/validation-public.d.ts.map +1 -0
- package/dist/validation-public.js +267 -0
- package/dist/validation-shared.d.ts +118 -0
- package/dist/validation-shared.d.ts.map +1 -0
- package/dist/validation-shared.js +102 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +3 -90
- 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
|
+
};
|