@voyant-travel/storefront 0.120.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 (126) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +231 -0
  3. package/dist/booking-intents.d.ts +42 -0
  4. package/dist/booking-intents.d.ts.map +1 -0
  5. package/dist/booking-intents.js +83 -0
  6. package/dist/customer-portal/index.d.ts +16 -0
  7. package/dist/customer-portal/index.d.ts.map +1 -0
  8. package/dist/customer-portal/index.js +23 -0
  9. package/dist/customer-portal/route-runtime.d.ts +16 -0
  10. package/dist/customer-portal/route-runtime.d.ts.map +1 -0
  11. package/dist/customer-portal/route-runtime.js +27 -0
  12. package/dist/customer-portal/routes-public.d.ts +1936 -0
  13. package/dist/customer-portal/routes-public.d.ts.map +1 -0
  14. package/dist/customer-portal/routes-public.js +165 -0
  15. package/dist/customer-portal/routes.d.ts +43 -0
  16. package/dist/customer-portal/routes.d.ts.map +1 -0
  17. package/dist/customer-portal/routes.js +17 -0
  18. package/dist/customer-portal/service-public-impl.d.ts +138 -0
  19. package/dist/customer-portal/service-public-impl.d.ts.map +1 -0
  20. package/dist/customer-portal/service-public-impl.js +1808 -0
  21. package/dist/customer-portal/service-public.d.ts +2 -0
  22. package/dist/customer-portal/service-public.d.ts.map +1 -0
  23. package/dist/customer-portal/service-public.js +1 -0
  24. package/dist/customer-portal/validation-public/bookings.d.ts +551 -0
  25. package/dist/customer-portal/validation-public/bookings.d.ts.map +1 -0
  26. package/dist/customer-portal/validation-public/bookings.js +132 -0
  27. package/dist/customer-portal/validation-public/common.d.ts +162 -0
  28. package/dist/customer-portal/validation-public/common.d.ts.map +1 -0
  29. package/dist/customer-portal/validation-public/common.js +139 -0
  30. package/dist/customer-portal/validation-public/profile.d.ts +749 -0
  31. package/dist/customer-portal/validation-public/profile.d.ts.map +1 -0
  32. package/dist/customer-portal/validation-public/profile.js +308 -0
  33. package/dist/customer-portal/validation-public.d.ts +3 -0
  34. package/dist/customer-portal/validation-public.d.ts.map +1 -0
  35. package/dist/customer-portal/validation-public.js +2 -0
  36. package/dist/guest-booking-guard.d.ts +24 -0
  37. package/dist/guest-booking-guard.d.ts.map +1 -0
  38. package/dist/guest-booking-guard.js +55 -0
  39. package/dist/index.d.ts +23 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +41 -0
  42. package/dist/product-extra-ref.d.ts +238 -0
  43. package/dist/product-extra-ref.d.ts.map +1 -0
  44. package/dist/product-extra-ref.js +22 -0
  45. package/dist/routes-admin.d.ts +220 -0
  46. package/dist/routes-admin.d.ts.map +1 -0
  47. package/dist/routes-admin.js +28 -0
  48. package/dist/routes-public.d.ts +1475 -0
  49. package/dist/routes-public.d.ts.map +1 -0
  50. package/dist/routes-public.js +362 -0
  51. package/dist/service-booking-session-bootstrap.d.ts +227 -0
  52. package/dist/service-booking-session-bootstrap.d.ts.map +1 -0
  53. package/dist/service-booking-session-bootstrap.js +287 -0
  54. package/dist/service-boundary-resource-sql.d.ts +18 -0
  55. package/dist/service-boundary-resource-sql.d.ts.map +1 -0
  56. package/dist/service-boundary-resource-sql.js +73 -0
  57. package/dist/service-boundary-sql.d.ts +103 -0
  58. package/dist/service-boundary-sql.d.ts.map +1 -0
  59. package/dist/service-boundary-sql.js +307 -0
  60. package/dist/service-departures-core.d.ts +41 -0
  61. package/dist/service-departures-core.d.ts.map +1 -0
  62. package/dist/service-departures-core.js +92 -0
  63. package/dist/service-departures-extensions.d.ts +46 -0
  64. package/dist/service-departures-extensions.d.ts.map +1 -0
  65. package/dist/service-departures-extensions.js +81 -0
  66. package/dist/service-departures-offers.d.ts +220 -0
  67. package/dist/service-departures-offers.d.ts.map +1 -0
  68. package/dist/service-departures-offers.js +177 -0
  69. package/dist/service-departures-price-preview.d.ts +306 -0
  70. package/dist/service-departures-price-preview.d.ts.map +1 -0
  71. package/dist/service-departures-price-preview.js +383 -0
  72. package/dist/service-departures-pricing-context.d.ts +115 -0
  73. package/dist/service-departures-pricing-context.d.ts.map +1 -0
  74. package/dist/service-departures-pricing-context.js +237 -0
  75. package/dist/service-departures-pricing.d.ts +5 -0
  76. package/dist/service-departures-pricing.d.ts.map +1 -0
  77. package/dist/service-departures-pricing.js +4 -0
  78. package/dist/service-departures.d.ts +192 -0
  79. package/dist/service-departures.d.ts.map +1 -0
  80. package/dist/service-departures.js +213 -0
  81. package/dist/service-intake.d.ts +130 -0
  82. package/dist/service-intake.d.ts.map +1 -0
  83. package/dist/service-intake.js +274 -0
  84. package/dist/service-transport-eligibility.d.ts +10 -0
  85. package/dist/service-transport-eligibility.d.ts.map +1 -0
  86. package/dist/service-transport-eligibility.js +198 -0
  87. package/dist/service.d.ts +1062 -0
  88. package/dist/service.d.ts.map +1 -0
  89. package/dist/service.js +332 -0
  90. package/dist/transport-eligibility.d.ts +4 -0
  91. package/dist/transport-eligibility.d.ts.map +1 -0
  92. package/dist/transport-eligibility.js +2 -0
  93. package/dist/validation/departures.d.ts +1669 -0
  94. package/dist/validation/departures.d.ts.map +1 -0
  95. package/dist/validation/departures.js +397 -0
  96. package/dist/validation/intake.d.ts +147 -0
  97. package/dist/validation/intake.d.ts.map +1 -0
  98. package/dist/validation/intake.js +69 -0
  99. package/dist/validation/offers.d.ts +340 -0
  100. package/dist/validation/offers.d.ts.map +1 -0
  101. package/dist/validation/offers.js +117 -0
  102. package/dist/validation-settings.d.ts +609 -0
  103. package/dist/validation-settings.d.ts.map +1 -0
  104. package/dist/validation-settings.js +235 -0
  105. package/dist/validation-transport-eligibility.d.ts +314 -0
  106. package/dist/validation-transport-eligibility.d.ts.map +1 -0
  107. package/dist/validation-transport-eligibility.js +97 -0
  108. package/dist/validation.d.ts +6 -0
  109. package/dist/validation.d.ts.map +1 -0
  110. package/dist/validation.js +4 -0
  111. package/dist/verification/index.d.ts +12 -0
  112. package/dist/verification/index.d.ts.map +1 -0
  113. package/dist/verification/index.js +18 -0
  114. package/dist/verification/routes-public.d.ts +121 -0
  115. package/dist/verification/routes-public.d.ts.map +1 -0
  116. package/dist/verification/routes-public.js +125 -0
  117. package/dist/verification/schema.d.ts +273 -0
  118. package/dist/verification/schema.d.ts.map +1 -0
  119. package/dist/verification/schema.js +50 -0
  120. package/dist/verification/service.d.ts +114 -0
  121. package/dist/verification/service.d.ts.map +1 -0
  122. package/dist/verification/service.js +283 -0
  123. package/dist/verification/validation.d.ts +98 -0
  124. package/dist/verification/validation.d.ts.map +1 -0
  125. package/dist/verification/validation.js +54 -0
  126. package/package.json +148 -0
@@ -0,0 +1,1808 @@
1
+ // agent-quality: file-size exception -- owner: customer-portal; existing service module stays co-located until a dedicated split preserves behavior and tests.
2
+ import { bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingSessionStates, bookingStaffAssignments, bookings, bookingTravelers, } from "@voyant-travel/bookings/schema";
3
+ import { authUser, userProfilesTable } from "@voyant-travel/db/schema/iam";
4
+ import { invoiceRenditions, invoices, payments } from "@voyant-travel/finance/schema";
5
+ import { identityContactPoints } from "@voyant-travel/identity/schema";
6
+ import { identityService } from "@voyant-travel/identity/service";
7
+ import { contractAttachments, contracts } from "@voyant-travel/legal/schema";
8
+ import { people, personDocumentNumberPlaintextSchema, personPiiBlobPlaintextSchema, relationshipsService, } from "@voyant-travel/relationships";
9
+ import { decryptOptionalJsonEnvelope, encryptOptionalJsonEnvelope, } from "@voyant-travel/utils";
10
+ import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
11
+ import { customerPortalBookingDetailSchema } from "./validation-public.js";
12
+ const linkedCustomerSource = "auth.user";
13
+ const companionMetadataKind = "companion";
14
+ const bookingWizardStateKey = "wizard";
15
+ const peopleKeyRef = { keyType: "people" };
16
+ function resolveMarketingConsentState(params) {
17
+ const currentConsent = params.currentConsent ?? false;
18
+ const nextConsent = params.nextConsent ?? currentConsent;
19
+ const currentConsentAt = params.currentConsentAt instanceof Date
20
+ ? params.currentConsentAt
21
+ : params.currentConsentAt
22
+ ? new Date(params.currentConsentAt)
23
+ : null;
24
+ const normalizedNextSource = params.nextConsentSource !== undefined
25
+ ? (normalizeNullableString(params.nextConsentSource) ?? null)
26
+ : (params.currentConsentSource ?? null);
27
+ return {
28
+ marketingConsent: nextConsent,
29
+ marketingConsentAt: params.nextConsent === undefined
30
+ ? currentConsentAt
31
+ : nextConsent
32
+ ? currentConsent
33
+ ? (currentConsentAt ?? new Date())
34
+ : new Date()
35
+ : null,
36
+ marketingConsentSource: nextConsent ? normalizedNextSource : null,
37
+ };
38
+ }
39
+ function normalizeDate(value) {
40
+ if (!value) {
41
+ return null;
42
+ }
43
+ if (value instanceof Date) {
44
+ return value.toISOString().slice(0, 10);
45
+ }
46
+ return value;
47
+ }
48
+ function normalizeDateTime(value) {
49
+ if (!value) {
50
+ return null;
51
+ }
52
+ return value instanceof Date ? value.toISOString() : value;
53
+ }
54
+ function normalizeNullableString(value) {
55
+ if (value === undefined) {
56
+ return undefined;
57
+ }
58
+ const trimmed = value?.trim();
59
+ return trimmed ? trimmed : null;
60
+ }
61
+ function normalizeEmail(value) {
62
+ return value.trim().toLowerCase();
63
+ }
64
+ function normalizePhone(value) {
65
+ return value.trim();
66
+ }
67
+ function normalizeCompanionLookupName(value) {
68
+ return value.trim().toLowerCase();
69
+ }
70
+ function deriveMiddleName(fullName, firstName, lastName) {
71
+ const normalizedFullName = fullName?.trim() ?? "";
72
+ if (!normalizedFullName) {
73
+ return null;
74
+ }
75
+ const normalizedFirstName = firstName?.trim() ?? "";
76
+ const normalizedLastName = lastName?.trim() ?? "";
77
+ let working = normalizedFullName;
78
+ if (normalizedFirstName && working.toLowerCase().startsWith(normalizedFirstName.toLowerCase())) {
79
+ working = working.slice(normalizedFirstName.length).trim();
80
+ }
81
+ if (normalizedLastName && working.toLowerCase().endsWith(normalizedLastName.toLowerCase())) {
82
+ working = working.slice(0, -normalizedLastName.length).trim();
83
+ }
84
+ return working.length > 0 ? working : null;
85
+ }
86
+ function toCrmDocumentType(type) {
87
+ return type === "drivers_license" ? "driver_license" : type;
88
+ }
89
+ function toWireDocumentType(type) {
90
+ return type === "driver_license" ? "drivers_license" : type;
91
+ }
92
+ function getMetadataRecord(value) {
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
94
+ return null;
95
+ }
96
+ return value;
97
+ }
98
+ function getMetadataString(record, keys) {
99
+ for (const key of keys) {
100
+ const value = record?.[key];
101
+ if (typeof value === "string" && value.length > 0) {
102
+ return value;
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ function getRecord(value) {
108
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
109
+ return null;
110
+ }
111
+ return value;
112
+ }
113
+ function toCustomerAddress(address) {
114
+ return {
115
+ id: address.id,
116
+ label: address.label,
117
+ fullText: address.fullText ?? null,
118
+ line1: address.line1 ?? null,
119
+ line2: address.line2 ?? null,
120
+ city: address.city ?? null,
121
+ region: address.region ?? null,
122
+ postalCode: address.postalCode ?? null,
123
+ country: address.country ?? null,
124
+ isPrimary: address.isPrimary,
125
+ };
126
+ }
127
+ function getNestedRecord(record, keys) {
128
+ for (const key of keys) {
129
+ const value = getRecord(record?.[key]);
130
+ if (value) {
131
+ return value;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ function getRecordString(record, keys) {
137
+ for (const key of keys) {
138
+ const value = record?.[key];
139
+ if (typeof value === "string") {
140
+ const trimmed = value.trim();
141
+ if (trimmed.length > 0) {
142
+ return trimmed;
143
+ }
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ function getRecordBoolean(record, keys) {
149
+ for (const key of keys) {
150
+ const value = record?.[key];
151
+ if (typeof value === "boolean") {
152
+ return value;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ function splitCompanionName(value) {
158
+ const parts = String(value ?? "")
159
+ .trim()
160
+ .split(/\s+/)
161
+ .filter(Boolean);
162
+ if (parts.length === 0) {
163
+ return {
164
+ firstName: null,
165
+ middleName: null,
166
+ lastName: null,
167
+ };
168
+ }
169
+ if (parts.length === 1) {
170
+ return {
171
+ firstName: parts[0] ?? null,
172
+ middleName: null,
173
+ lastName: null,
174
+ };
175
+ }
176
+ return {
177
+ firstName: parts[0] ?? null,
178
+ middleName: parts.length > 2 ? parts.slice(1, -1).join(" ") : null,
179
+ lastName: parts.at(-1) ?? null,
180
+ };
181
+ }
182
+ function normalizeCompanionAddressRecord(value) {
183
+ return {
184
+ type: getRecordString(value, ["type"]) ?? null,
185
+ country: getRecordString(value, ["country"]) ?? null,
186
+ state: getRecordString(value, ["state", "region"]) ?? null,
187
+ city: getRecordString(value, ["city"]) ?? null,
188
+ postalCode: getRecordString(value, ["postalCode", "postal"]) ?? null,
189
+ addressLine1: getRecordString(value, ["addressLine1", "line1"]) ?? null,
190
+ addressLine2: getRecordString(value, ["addressLine2", "line2"]) ?? null,
191
+ isDefault: getRecordBoolean(value, ["isDefault"]) ?? false,
192
+ };
193
+ }
194
+ function normalizeCompanionDocumentRecord(value) {
195
+ const type = getRecordString(value, ["type"]);
196
+ if (type !== "passport" &&
197
+ type !== "id_card" &&
198
+ type !== "visa" &&
199
+ type !== "drivers_license" &&
200
+ type !== "other") {
201
+ return null;
202
+ }
203
+ return {
204
+ type,
205
+ number: getRecordString(value, ["number"]) ?? null,
206
+ issuingAuthority: getRecordString(value, ["issuingAuthority"]) ?? null,
207
+ country: getRecordString(value, ["country", "issuingCountry"]) ?? null,
208
+ issueDate: getRecordString(value, ["issueDate"]) ?? null,
209
+ expiryDate: getRecordString(value, ["expiryDate"]) ?? null,
210
+ };
211
+ }
212
+ function getCompanionPersonMetadata(metadata) {
213
+ const personMetadata = getNestedRecord(metadata, ["person", "traveler", "identity"]);
214
+ const derivedName = splitCompanionName(getRecordString(metadata, ["name"]));
215
+ const addresses = Array.isArray(personMetadata?.addresses)
216
+ ? personMetadata.addresses
217
+ .map((value) => normalizeCompanionAddressRecord(getRecord(value)))
218
+ .filter(Boolean)
219
+ : [];
220
+ const documents = Array.isArray(personMetadata?.documents)
221
+ ? personMetadata.documents
222
+ .map((value) => normalizeCompanionDocumentRecord(getRecord(value)))
223
+ .filter((value) => Boolean(value))
224
+ : [];
225
+ return {
226
+ firstName: getRecordString(personMetadata, ["firstName"]) ?? derivedName.firstName,
227
+ middleName: getRecordString(personMetadata, ["middleName"]) ?? derivedName.middleName,
228
+ lastName: getRecordString(personMetadata, ["lastName"]) ?? derivedName.lastName,
229
+ dateOfBirth: getRecordString(personMetadata, ["dateOfBirth"]) ?? null,
230
+ addresses,
231
+ documents,
232
+ };
233
+ }
234
+ function getCompanionTypeKey(metadata) {
235
+ return getRecordString(metadata, ["typeKey", "relationshipType"]);
236
+ }
237
+ function buildStoredCompanionMetadata(input) {
238
+ const baseMetadata = input.metadata !== undefined
239
+ ? { ...(input.metadata ?? {}) }
240
+ : { ...(input.existingMetadata ?? {}) };
241
+ baseMetadata.kind = companionMetadataKind;
242
+ if (input.typeKey !== undefined) {
243
+ const typeKey = normalizeNullableString(input.typeKey);
244
+ if (typeKey) {
245
+ baseMetadata.typeKey = typeKey;
246
+ }
247
+ else {
248
+ delete baseMetadata.typeKey;
249
+ }
250
+ }
251
+ if (input.person !== undefined) {
252
+ baseMetadata.person = {
253
+ firstName: normalizeNullableString(input.person.firstName) ?? null,
254
+ middleName: normalizeNullableString(input.person.middleName) ?? null,
255
+ lastName: normalizeNullableString(input.person.lastName) ?? null,
256
+ dateOfBirth: normalizeNullableString(input.person.dateOfBirth) ?? null,
257
+ addresses: input.person.addresses?.map((address) => ({
258
+ type: normalizeNullableString(address.type) ?? null,
259
+ country: normalizeNullableString(address.country) ?? null,
260
+ state: normalizeNullableString(address.state) ?? null,
261
+ city: normalizeNullableString(address.city) ?? null,
262
+ postalCode: normalizeNullableString(address.postalCode) ?? null,
263
+ addressLine1: normalizeNullableString(address.addressLine1) ?? null,
264
+ addressLine2: normalizeNullableString(address.addressLine2) ?? null,
265
+ isDefault: address.isDefault ?? false,
266
+ })) ?? [],
267
+ documents: input.person.documents?.map((document) => ({
268
+ type: document.type,
269
+ number: normalizeNullableString(document.number) ?? null,
270
+ issuingAuthority: normalizeNullableString(document.issuingAuthority) ?? null,
271
+ country: normalizeNullableString(document.country) ?? null,
272
+ issueDate: normalizeNullableString(document.issueDate) ?? null,
273
+ expiryDate: normalizeNullableString(document.expiryDate) ?? null,
274
+ })) ?? [],
275
+ };
276
+ }
277
+ return baseMetadata;
278
+ }
279
+ function selectPreferredAddress(addresses) {
280
+ return (addresses.find((address) => address.label === "billing") ??
281
+ addresses.find((address) => address.isPrimary) ??
282
+ addresses[0] ??
283
+ null);
284
+ }
285
+ function resolveBillingContactFromSessionPayload(payload) {
286
+ const root = getRecord(payload);
287
+ const stepData = getNestedRecord(root, ["stepData", "steps"]);
288
+ const billingRecord = getNestedRecord(root, ["billing", "billingContact", "contact"]) ??
289
+ getNestedRecord(stepData, ["billing", "billingContact", "contact"]);
290
+ const billing = getNestedRecord(billingRecord, ["billing", "contact"]) ?? billingRecord;
291
+ if (!billing) {
292
+ return null;
293
+ }
294
+ return {
295
+ email: getRecordString(billing, ["email"]),
296
+ phone: getRecordString(billing, ["phone"]),
297
+ firstName: getRecordString(billing, ["firstName"]),
298
+ lastName: getRecordString(billing, ["lastName"]),
299
+ country: getRecordString(billing, ["country"]),
300
+ state: getRecordString(billing, ["state", "region"]),
301
+ city: getRecordString(billing, ["city"]),
302
+ address1: getRecordString(billing, ["addressLine1", "address1", "line1"]),
303
+ address2: getRecordString(billing, ["addressLine2", "address2", "line2"]),
304
+ postal: getRecordString(billing, ["postalCode", "postal", "zip"]),
305
+ };
306
+ }
307
+ function resolveFinanceDocumentFileName(invoiceNumber, invoiceType, format) {
308
+ const extension = format ?? "pdf";
309
+ return `${invoiceType}-${invoiceNumber}.${extension}`;
310
+ }
311
+ async function listLegalDocumentsForBooking(db, bookingId, options = {}) {
312
+ const contractRows = await db
313
+ .select({
314
+ id: contracts.id,
315
+ contractNumber: contracts.contractNumber,
316
+ })
317
+ .from(contracts)
318
+ .where(eq(contracts.bookingId, bookingId))
319
+ .orderBy(desc(contracts.createdAt));
320
+ if (contractRows.length === 0) {
321
+ return [];
322
+ }
323
+ const attachmentRows = await db
324
+ .select()
325
+ .from(contractAttachments)
326
+ .where(and(eq(contractAttachments.kind, "document"), or(...contractRows.map((contract) => eq(contractAttachments.contractId, contract.id)))))
327
+ .orderBy(desc(contractAttachments.createdAt));
328
+ const bestAttachmentByContractId = new Map();
329
+ for (const attachment of attachmentRows) {
330
+ const metadata = getMetadataRecord(attachment.metadata);
331
+ const downloadUrl = attachment.storageKey && options.resolveDocumentDownloadUrl
332
+ ? await options.resolveDocumentDownloadUrl(attachment.storageKey)
333
+ : getMetadataString(metadata, ["url"]);
334
+ if (!downloadUrl || bestAttachmentByContractId.has(attachment.contractId)) {
335
+ continue;
336
+ }
337
+ bestAttachmentByContractId.set(attachment.contractId, { attachment, downloadUrl });
338
+ }
339
+ return contractRows.flatMap((contract) => {
340
+ const document = bestAttachmentByContractId.get(contract.id);
341
+ if (!document) {
342
+ return [];
343
+ }
344
+ const { attachment, downloadUrl } = document;
345
+ return [
346
+ {
347
+ id: attachment.id,
348
+ source: "legal",
349
+ travelerId: null,
350
+ type: "contract",
351
+ fileName: attachment.name,
352
+ fileUrl: downloadUrl,
353
+ mimeType: attachment.mimeType ?? null,
354
+ reference: contract.contractNumber ?? null,
355
+ },
356
+ ];
357
+ });
358
+ }
359
+ function resolveFinanceDocumentDownloadUrl(metadata) {
360
+ return getMetadataString(metadata, ["url"]);
361
+ }
362
+ function selectBookingSummaryProductTitle(items) {
363
+ const preferredItem = items.find((item) => item.itemType === "unit") ??
364
+ items.find((item) => item.itemType === "accommodation") ??
365
+ items.find((item) => item.itemType === "transport") ??
366
+ items[0] ??
367
+ null;
368
+ return preferredItem?.title ?? null;
369
+ }
370
+ function deriveBookingSummaryPaymentStatus(invoicesForBooking, fallbackSellAmountCents) {
371
+ const activeInvoices = invoicesForBooking.filter((invoice) => invoice.invoiceType !== "credit_note" && invoice.status !== "void");
372
+ if (activeInvoices.length === 0) {
373
+ return fallbackSellAmountCents && fallbackSellAmountCents > 0 ? "unpaid" : "paid";
374
+ }
375
+ if (activeInvoices.some((invoice) => invoice.status === "overdue" && invoice.balanceDueCents > 0)) {
376
+ return "overdue";
377
+ }
378
+ const totalPaidCents = activeInvoices.reduce((sum, invoice) => sum + Math.max(0, invoice.paidCents), 0);
379
+ const totalBalanceDueCents = activeInvoices.reduce((sum, invoice) => sum + Math.max(0, invoice.balanceDueCents), 0);
380
+ if (totalBalanceDueCents <= 0) {
381
+ return "paid";
382
+ }
383
+ if (totalPaidCents > 0) {
384
+ return "partially_paid";
385
+ }
386
+ return "unpaid";
387
+ }
388
+ async function getFinanceDataForBooking(db, bookingId, options = {}) {
389
+ const invoiceRows = await db
390
+ .select()
391
+ .from(invoices)
392
+ .where(eq(invoices.bookingId, bookingId))
393
+ .orderBy(desc(invoices.createdAt));
394
+ if (invoiceRows.length === 0) {
395
+ return { documents: [], payments: [], portalDocuments: [] };
396
+ }
397
+ const invoiceIds = invoiceRows.map((invoice) => invoice.id);
398
+ const renditionRows = await db
399
+ .select()
400
+ .from(invoiceRenditions)
401
+ .where(inArray(invoiceRenditions.invoiceId, invoiceIds))
402
+ .orderBy(desc(invoiceRenditions.createdAt));
403
+ const paymentRows = await db
404
+ .select()
405
+ .from(payments)
406
+ .where(inArray(payments.invoiceId, invoiceIds))
407
+ .orderBy(desc(payments.paymentDate), desc(payments.createdAt));
408
+ const renditionByInvoiceId = new Map();
409
+ for (const rendition of renditionRows) {
410
+ const existing = renditionByInvoiceId.get(rendition.invoiceId) ?? [];
411
+ existing.push(rendition);
412
+ renditionByInvoiceId.set(rendition.invoiceId, existing);
413
+ }
414
+ const invoiceById = new Map(invoiceRows.map((invoice) => [invoice.id, invoice]));
415
+ const resolvedDocuments = await Promise.all(invoiceRows.map(async (invoice) => {
416
+ const renditions = renditionByInvoiceId.get(invoice.id) ?? [];
417
+ const selectedRendition = renditions.find((rendition) => rendition.status === "ready") ?? renditions[0] ?? null;
418
+ const metadata = getMetadataRecord(selectedRendition?.metadata ?? null);
419
+ const downloadUrl = selectedRendition?.storageKey && options.resolveDocumentDownloadUrl
420
+ ? await options.resolveDocumentDownloadUrl(selectedRendition.storageKey)
421
+ : resolveFinanceDocumentDownloadUrl(metadata);
422
+ return {
423
+ invoiceId: invoice.id,
424
+ invoiceNumber: invoice.invoiceNumber,
425
+ invoiceType: invoice.invoiceType,
426
+ invoiceStatus: invoice.status,
427
+ currency: invoice.currency,
428
+ totalCents: invoice.totalCents,
429
+ paidCents: invoice.paidCents,
430
+ balanceDueCents: invoice.balanceDueCents,
431
+ issueDate: invoice.issueDate,
432
+ dueDate: invoice.dueDate,
433
+ documentStatus: selectedRendition?.status ?? "missing",
434
+ format: selectedRendition?.format ?? null,
435
+ generatedAt: normalizeDateTime(selectedRendition?.generatedAt ?? null),
436
+ downloadUrl,
437
+ };
438
+ }));
439
+ const paymentHistory = paymentRows.flatMap((payment) => {
440
+ const invoice = invoiceById.get(payment.invoiceId);
441
+ if (!invoice) {
442
+ return [];
443
+ }
444
+ return [
445
+ {
446
+ id: payment.id,
447
+ invoiceId: invoice.id,
448
+ invoiceNumber: invoice.invoiceNumber,
449
+ invoiceType: invoice.invoiceType,
450
+ status: payment.status,
451
+ paymentMethod: payment.paymentMethod,
452
+ amountCents: payment.amountCents,
453
+ currency: payment.currency,
454
+ paymentDate: payment.paymentDate,
455
+ referenceNumber: payment.referenceNumber ?? null,
456
+ notes: payment.notes ?? null,
457
+ },
458
+ ];
459
+ });
460
+ const portalDocuments = resolvedDocuments.flatMap((document) => {
461
+ if (!document.downloadUrl) {
462
+ return [];
463
+ }
464
+ return [
465
+ {
466
+ id: document.invoiceId,
467
+ source: "finance",
468
+ travelerId: null,
469
+ type: document.invoiceType,
470
+ fileName: resolveFinanceDocumentFileName(document.invoiceNumber, document.invoiceType, document.format),
471
+ fileUrl: document.downloadUrl,
472
+ mimeType: document.format === "pdf" ? "application/pdf" : null,
473
+ reference: document.invoiceNumber,
474
+ },
475
+ ];
476
+ });
477
+ return { documents: resolvedDocuments, payments: paymentHistory, portalDocuments };
478
+ }
479
+ function toCustomerCompanion(row) {
480
+ const metadata = row.metadata ?? null;
481
+ return {
482
+ id: row.id,
483
+ role: row.role,
484
+ name: row.name,
485
+ title: row.title ?? null,
486
+ email: row.email ?? null,
487
+ phone: row.phone ?? null,
488
+ isPrimary: row.isPrimary,
489
+ notes: row.notes ?? null,
490
+ typeKey: getCompanionTypeKey(metadata) ?? null,
491
+ person: getCompanionPersonMetadata({
492
+ ...metadata,
493
+ name: row.name,
494
+ }),
495
+ metadata,
496
+ };
497
+ }
498
+ function getCompanionLookupKeys(input) {
499
+ const keys = [normalizeCompanionLookupName(input.name)];
500
+ if (input.email) {
501
+ keys.push(`email:${normalizeEmail(input.email)}`);
502
+ }
503
+ if (input.phone) {
504
+ keys.push(`phone:${normalizePhone(input.phone)}`);
505
+ }
506
+ return keys;
507
+ }
508
+ async function getAuthProfileRow(db, userId) {
509
+ const [row] = await db
510
+ .select({
511
+ id: authUser.id,
512
+ email: authUser.email,
513
+ emailVerified: authUser.emailVerified,
514
+ name: authUser.name,
515
+ image: authUser.image,
516
+ firstName: userProfilesTable.firstName,
517
+ lastName: userProfilesTable.lastName,
518
+ avatarUrl: userProfilesTable.avatarUrl,
519
+ locale: userProfilesTable.locale,
520
+ timezone: userProfilesTable.timezone,
521
+ seatingPreference: userProfilesTable.seatingPreference,
522
+ marketingConsent: userProfilesTable.marketingConsent,
523
+ marketingConsentAt: userProfilesTable.marketingConsentAt,
524
+ marketingConsentSource: userProfilesTable.marketingConsentSource,
525
+ notificationDefaults: userProfilesTable.notificationDefaults,
526
+ uiPrefs: userProfilesTable.uiPrefs,
527
+ })
528
+ .from(authUser)
529
+ .leftJoin(userProfilesTable, eq(userProfilesTable.id, authUser.id))
530
+ .where(eq(authUser.id, userId))
531
+ .limit(1);
532
+ return row ?? null;
533
+ }
534
+ async function decryptProfileBlob(envelope, options) {
535
+ if (!envelope || !options?.kms) {
536
+ return null;
537
+ }
538
+ const decrypted = await decryptOptionalJsonEnvelope(options.kms, peopleKeyRef, envelope, personPiiBlobPlaintextSchema);
539
+ return decrypted?.text ?? null;
540
+ }
541
+ async function encryptProfileBlob(value, options) {
542
+ if (!options?.kms) {
543
+ return undefined;
544
+ }
545
+ if (value === null) {
546
+ return null;
547
+ }
548
+ return encryptOptionalJsonEnvelope(options.kms, peopleKeyRef, { text: value });
549
+ }
550
+ async function decryptDocumentNumber(envelope, options) {
551
+ if (!envelope || !options?.kms) {
552
+ return null;
553
+ }
554
+ const decrypted = await decryptOptionalJsonEnvelope(options.kms, peopleKeyRef, envelope, personDocumentNumberPlaintextSchema);
555
+ return decrypted?.number ?? null;
556
+ }
557
+ async function encryptDocumentNumber(value, options) {
558
+ if (!options?.kms) {
559
+ return undefined;
560
+ }
561
+ if (value == null) {
562
+ return null;
563
+ }
564
+ return encryptOptionalJsonEnvelope(options.kms, peopleKeyRef, { number: value });
565
+ }
566
+ async function projectPersonDocumentToWire(row, options) {
567
+ return {
568
+ id: row.id,
569
+ type: toWireDocumentType(row.type),
570
+ number: await decryptDocumentNumber(row.numberEncrypted, options),
571
+ issuingAuthority: row.issuingAuthority ?? null,
572
+ issuingCountry: row.issuingCountry ?? null,
573
+ issueDate: row.issueDate ?? null,
574
+ expiryDate: row.expiryDate ?? null,
575
+ attachmentId: row.attachmentId ?? null,
576
+ isPrimary: row.isPrimary,
577
+ notes: row.notes ?? null,
578
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
579
+ updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
580
+ };
581
+ }
582
+ async function getLinkedPersonPiiRow(db, userId) {
583
+ const [row] = await db
584
+ .select({
585
+ id: people.id,
586
+ accessibilityEncrypted: people.accessibilityEncrypted,
587
+ dietaryEncrypted: people.dietaryEncrypted,
588
+ loyaltyEncrypted: people.loyaltyEncrypted,
589
+ insuranceEncrypted: people.insuranceEncrypted,
590
+ })
591
+ .from(people)
592
+ .where(and(eq(people.source, linkedCustomerSource), eq(people.sourceRef, userId)))
593
+ .limit(1);
594
+ return row ?? null;
595
+ }
596
+ async function getLinkedPersonDocuments(db, userId, options) {
597
+ const linked = await resolveLinkedCustomerRecordId(db, userId);
598
+ if (!linked) {
599
+ return [];
600
+ }
601
+ const rows = await relationshipsService.listPersonDocuments(db, linked);
602
+ return Promise.all(rows.map((row) => projectPersonDocumentToWire(row, options)));
603
+ }
604
+ /**
605
+ * Resolves the `crm.people` row linked to this auth user, creating
606
+ * it on first PII write if missing. The seed values mirror what the
607
+ * bootstrap path already produces — first/last name from the auth
608
+ * profile, source/sourceRef pinned to `auth.user`/`userId` so future
609
+ * reads find the same row.
610
+ */
611
+ async function ensureLinkedPerson(db, userId, authProfile) {
612
+ const existing = await resolveLinkedCustomerRecordId(db, userId);
613
+ if (existing)
614
+ return existing;
615
+ const fallbackFirst = authProfile.firstName ?? authProfile.name.split(" ")[0]?.trim() ?? "Customer";
616
+ const fallbackLast = authProfile.lastName ?? (authProfile.name.split(" ").slice(1).join(" ").trim() || "");
617
+ const created = await relationshipsService.createPerson(db, {
618
+ firstName: fallbackFirst,
619
+ lastName: fallbackLast,
620
+ tags: [],
621
+ status: "active",
622
+ source: linkedCustomerSource,
623
+ sourceRef: userId,
624
+ website: null,
625
+ });
626
+ if (!created) {
627
+ throw new Error("Failed to create linked customer record");
628
+ }
629
+ return created.id;
630
+ }
631
+ async function resolveLinkedCustomerRecordId(db, userId) {
632
+ const [row] = await db
633
+ .select({ id: people.id })
634
+ .from(people)
635
+ .where(and(eq(people.source, linkedCustomerSource), eq(people.sourceRef, userId)))
636
+ .limit(1);
637
+ return row?.id ?? null;
638
+ }
639
+ async function listCustomerRecordCandidatesByEmail(db, email) {
640
+ const normalizedEmail = normalizeEmail(email);
641
+ const rows = await db
642
+ .select({
643
+ id: people.id,
644
+ firstName: people.firstName,
645
+ lastName: people.lastName,
646
+ preferredLanguage: people.preferredLanguage,
647
+ preferredCurrency: people.preferredCurrency,
648
+ dateOfBirth: people.dateOfBirth,
649
+ relation: people.relation,
650
+ status: people.status,
651
+ source: people.source,
652
+ sourceRef: people.sourceRef,
653
+ })
654
+ .from(people)
655
+ .innerJoin(identityContactPoints, and(eq(identityContactPoints.entityType, "person"), eq(identityContactPoints.entityId, people.id), eq(identityContactPoints.kind, "email"), eq(identityContactPoints.normalizedValue, normalizedEmail)))
656
+ .orderBy(desc(people.updatedAt));
657
+ const uniqueRows = new Map();
658
+ for (const row of rows) {
659
+ if (!uniqueRows.has(row.id)) {
660
+ uniqueRows.set(row.id, row);
661
+ }
662
+ }
663
+ const candidates = Array.from(uniqueRows.values()).map((row) => ({
664
+ id: row.id,
665
+ firstName: row.firstName,
666
+ lastName: row.lastName,
667
+ preferredLanguage: row.preferredLanguage ?? null,
668
+ preferredCurrency: row.preferredCurrency ?? null,
669
+ dateOfBirth: row.dateOfBirth ?? null,
670
+ email: normalizedEmail,
671
+ phone: null,
672
+ billingAddress: null,
673
+ relation: row.relation ?? null,
674
+ status: row.status,
675
+ claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
676
+ linkable: row.source === linkedCustomerSource ? row.sourceRef == null : row.sourceRef == null,
677
+ }));
678
+ return candidates;
679
+ }
680
+ async function listCustomerRecordCandidatesByPhone(db, phone) {
681
+ const normalizedPhone = normalizePhone(phone);
682
+ const rows = await db
683
+ .select({
684
+ id: people.id,
685
+ firstName: people.firstName,
686
+ lastName: people.lastName,
687
+ preferredLanguage: people.preferredLanguage,
688
+ preferredCurrency: people.preferredCurrency,
689
+ dateOfBirth: people.dateOfBirth,
690
+ relation: people.relation,
691
+ status: people.status,
692
+ source: people.source,
693
+ sourceRef: people.sourceRef,
694
+ })
695
+ .from(people)
696
+ .innerJoin(identityContactPoints, and(eq(identityContactPoints.entityType, "person"), eq(identityContactPoints.entityId, people.id), inArray(identityContactPoints.kind, ["phone", "mobile", "whatsapp", "sms"]), or(eq(identityContactPoints.normalizedValue, normalizedPhone), eq(identityContactPoints.value, normalizedPhone))))
697
+ .orderBy(desc(people.updatedAt));
698
+ const uniqueRows = new Map();
699
+ for (const row of rows) {
700
+ if (!uniqueRows.has(row.id)) {
701
+ uniqueRows.set(row.id, row);
702
+ }
703
+ }
704
+ return Array.from(uniqueRows.values()).map((row) => ({
705
+ id: row.id,
706
+ firstName: row.firstName,
707
+ lastName: row.lastName,
708
+ preferredLanguage: row.preferredLanguage ?? null,
709
+ preferredCurrency: row.preferredCurrency ?? null,
710
+ dateOfBirth: row.dateOfBirth ?? null,
711
+ email: null,
712
+ phone: normalizedPhone,
713
+ billingAddress: null,
714
+ relation: row.relation ?? null,
715
+ status: row.status,
716
+ claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
717
+ linkable: row.source === linkedCustomerSource ? row.sourceRef == null : row.sourceRef == null,
718
+ }));
719
+ }
720
+ async function getCustomerRecord(db, userId) {
721
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
722
+ if (!personId) {
723
+ return null;
724
+ }
725
+ const [person, addresses] = await Promise.all([
726
+ relationshipsService.getPersonById(db, personId),
727
+ identityService.listAddressesForEntity(db, "person", personId),
728
+ ]);
729
+ if (!person) {
730
+ return null;
731
+ }
732
+ const billingAddress = selectPreferredAddress(addresses);
733
+ return {
734
+ id: person.id,
735
+ firstName: person.firstName,
736
+ lastName: person.lastName,
737
+ preferredLanguage: person.preferredLanguage ?? null,
738
+ preferredCurrency: person.preferredCurrency ?? null,
739
+ dateOfBirth: person.dateOfBirth ?? null,
740
+ email: person.email ?? null,
741
+ phone: person.phone ?? null,
742
+ billingAddress: billingAddress ? toCustomerAddress(billingAddress) : null,
743
+ relation: person.relation ?? null,
744
+ status: person.status,
745
+ };
746
+ }
747
+ async function upsertCustomerBillingAddress(db, personId, input) {
748
+ const existingAddresses = await identityService.listAddressesForEntity(db, "person", personId);
749
+ const existingAddress = selectPreferredAddress(existingAddresses);
750
+ const merged = {
751
+ label: input.label ?? existingAddress?.label ?? "billing",
752
+ fullText: normalizeNullableString(input.fullText) ?? existingAddress?.fullText ?? null,
753
+ line1: normalizeNullableString(input.line1) ?? existingAddress?.line1 ?? null,
754
+ line2: normalizeNullableString(input.line2) ?? existingAddress?.line2 ?? null,
755
+ city: normalizeNullableString(input.city) ?? existingAddress?.city ?? null,
756
+ region: normalizeNullableString(input.region) ?? existingAddress?.region ?? null,
757
+ postalCode: normalizeNullableString(input.postalCode) ?? existingAddress?.postalCode ?? null,
758
+ country: normalizeNullableString(input.country) ?? existingAddress?.country ?? null,
759
+ isPrimary: input.isPrimary ?? existingAddress?.isPrimary ?? existingAddresses.length === 0,
760
+ };
761
+ if (existingAddress) {
762
+ return identityService.updateAddress(db, existingAddress.id, merged);
763
+ }
764
+ return identityService.createAddress(db, {
765
+ entityType: "person",
766
+ entityId: personId,
767
+ ...merged,
768
+ });
769
+ }
770
+ async function getAccessibleBookingIds(db, params) {
771
+ const linkedPersonId = await resolveLinkedCustomerRecordId(db, params.userId);
772
+ const email = params.email?.trim().toLowerCase() ?? null;
773
+ const [directBookingRows, participantPersonRows, participantEmailRows] = await Promise.all([
774
+ linkedPersonId
775
+ ? db
776
+ .select({ bookingId: bookings.id })
777
+ .from(bookings)
778
+ .where(eq(bookings.personId, linkedPersonId))
779
+ : Promise.resolve([]),
780
+ linkedPersonId
781
+ ? db
782
+ .select({ bookingId: bookingTravelers.bookingId })
783
+ .from(bookingTravelers)
784
+ .where(eq(bookingTravelers.personId, linkedPersonId))
785
+ : Promise.resolve([]),
786
+ // Phone-only users have no email to match on — fall back to linked-person matching only.
787
+ email
788
+ ? db
789
+ .select({ bookingId: bookingTravelers.bookingId })
790
+ .from(bookingTravelers)
791
+ // agent-quality: raw-sql reviewed -- owner: customer-portal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
792
+ .where(sql `lower(${bookingTravelers.email}) = ${email}`)
793
+ : Promise.resolve([]),
794
+ ]);
795
+ return Array.from(new Set([...directBookingRows, ...participantPersonRows, ...participantEmailRows].map((row) => row.bookingId)));
796
+ }
797
+ async function hasBookingAccess(params) {
798
+ const ownershipConditions = [];
799
+ if (params.authEmail) {
800
+ // agent-quality: raw-sql reviewed -- owner: customer-portal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
801
+ ownershipConditions.push(sql `lower(${bookingTravelers.email}) = ${params.authEmail}`);
802
+ }
803
+ if (params.linkedPersonId) {
804
+ ownershipConditions.push(eq(bookingTravelers.personId, params.linkedPersonId));
805
+ }
806
+ if (ownershipConditions.length === 0) {
807
+ return false;
808
+ }
809
+ const [participantMatch, bookingMatch] = await Promise.all([
810
+ params.db
811
+ .select({ bookingId: bookingTravelers.bookingId })
812
+ .from(bookingTravelers)
813
+ .where(and(eq(bookingTravelers.bookingId, params.bookingId), or(...ownershipConditions)))
814
+ .limit(1),
815
+ params.linkedPersonId
816
+ ? params.db
817
+ .select({ bookingId: bookings.id })
818
+ .from(bookings)
819
+ .where(and(eq(bookings.id, params.bookingId), eq(bookings.personId, params.linkedPersonId)))
820
+ .limit(1)
821
+ : Promise.resolve([]),
822
+ ]);
823
+ return Boolean(participantMatch[0] || bookingMatch[0]);
824
+ }
825
+ async function getBookingBillingContact(db, bookingId, customerRecord) {
826
+ const [bookingRows, stateRows, primaryParticipantRows] = await Promise.all([
827
+ db
828
+ .select({
829
+ contactFirstName: bookings.contactFirstName,
830
+ contactLastName: bookings.contactLastName,
831
+ contactEmail: bookings.contactEmail,
832
+ contactPhone: bookings.contactPhone,
833
+ contactCountry: bookings.contactCountry,
834
+ contactRegion: bookings.contactRegion,
835
+ contactCity: bookings.contactCity,
836
+ contactAddressLine1: bookings.contactAddressLine1,
837
+ contactAddressLine2: bookings.contactAddressLine2,
838
+ contactPostalCode: bookings.contactPostalCode,
839
+ })
840
+ .from(bookings)
841
+ .where(eq(bookings.id, bookingId))
842
+ .limit(1),
843
+ db
844
+ .select({ payload: bookingSessionStates.payload })
845
+ .from(bookingSessionStates)
846
+ .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, bookingWizardStateKey)))
847
+ .limit(1),
848
+ db
849
+ .select({
850
+ firstName: bookingTravelers.firstName,
851
+ lastName: bookingTravelers.lastName,
852
+ email: bookingTravelers.email,
853
+ phone: bookingTravelers.phone,
854
+ })
855
+ .from(bookingTravelers)
856
+ .where(and(eq(bookingTravelers.bookingId, bookingId), eq(bookingTravelers.isPrimary, true)))
857
+ .orderBy(asc(bookingTravelers.createdAt))
858
+ .limit(1),
859
+ ]);
860
+ const booking = bookingRows[0] ?? null;
861
+ const stateRow = stateRows[0] ?? null;
862
+ const primaryParticipant = primaryParticipantRows[0] ?? null;
863
+ const sessionBillingContact = resolveBillingContactFromSessionPayload(stateRow?.payload ?? null);
864
+ const billingAddress = customerRecord?.billingAddress ?? null;
865
+ const result = {
866
+ email: booking?.contactEmail ??
867
+ sessionBillingContact?.email ??
868
+ primaryParticipant?.email ??
869
+ customerRecord?.email ??
870
+ null,
871
+ phone: booking?.contactPhone ??
872
+ sessionBillingContact?.phone ??
873
+ primaryParticipant?.phone ??
874
+ customerRecord?.phone ??
875
+ null,
876
+ firstName: booking?.contactFirstName ??
877
+ sessionBillingContact?.firstName ??
878
+ primaryParticipant?.firstName ??
879
+ customerRecord?.firstName ??
880
+ null,
881
+ lastName: booking?.contactLastName ??
882
+ sessionBillingContact?.lastName ??
883
+ primaryParticipant?.lastName ??
884
+ customerRecord?.lastName ??
885
+ null,
886
+ country: booking?.contactCountry ?? sessionBillingContact?.country ?? billingAddress?.country ?? null,
887
+ state: booking?.contactRegion ?? sessionBillingContact?.state ?? billingAddress?.region ?? null,
888
+ city: booking?.contactCity ?? sessionBillingContact?.city ?? billingAddress?.city ?? null,
889
+ address1: booking?.contactAddressLine1 ??
890
+ sessionBillingContact?.address1 ??
891
+ billingAddress?.line1 ??
892
+ null,
893
+ address2: booking?.contactAddressLine2 ??
894
+ sessionBillingContact?.address2 ??
895
+ billingAddress?.line2 ??
896
+ null,
897
+ postal: booking?.contactPostalCode ??
898
+ sessionBillingContact?.postal ??
899
+ billingAddress?.postalCode ??
900
+ null,
901
+ };
902
+ const hasValue = Object.values(result).some((value) => typeof value === "string" && value.length > 0);
903
+ return hasValue ? result : null;
904
+ }
905
+ async function buildBookingDetail(db, bookingId, customerRecord = null, options = {}) {
906
+ const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
907
+ if (!booking) {
908
+ return null;
909
+ }
910
+ const [participants, items, itemParticipantLinks, documents, fulfillments, legalDocuments, financeData, billingContact,] = await Promise.all([
911
+ db
912
+ .select()
913
+ .from(bookingTravelers)
914
+ .where(eq(bookingTravelers.bookingId, booking.id))
915
+ .orderBy(asc(bookingTravelers.createdAt)),
916
+ db
917
+ .select()
918
+ .from(bookingItems)
919
+ .where(eq(bookingItems.bookingId, booking.id))
920
+ .orderBy(asc(bookingItems.createdAt)),
921
+ db
922
+ .select({
923
+ id: bookingItemTravelers.id,
924
+ bookingItemId: bookingItemTravelers.bookingItemId,
925
+ travelerId: bookingItemTravelers.travelerId,
926
+ role: bookingItemTravelers.role,
927
+ isPrimary: bookingItemTravelers.isPrimary,
928
+ })
929
+ .from(bookingItemTravelers)
930
+ .innerJoin(bookingItems, eq(bookingItems.id, bookingItemTravelers.bookingItemId))
931
+ .where(eq(bookingItems.bookingId, booking.id))
932
+ .orderBy(asc(bookingItemTravelers.createdAt)),
933
+ db
934
+ .select()
935
+ .from(bookingDocuments)
936
+ .where(eq(bookingDocuments.bookingId, booking.id))
937
+ .orderBy(asc(bookingDocuments.createdAt)),
938
+ db
939
+ .select()
940
+ .from(bookingFulfillments)
941
+ .where(eq(bookingFulfillments.bookingId, booking.id))
942
+ .orderBy(asc(bookingFulfillments.createdAt)),
943
+ listLegalDocumentsForBooking(db, booking.id, options),
944
+ getFinanceDataForBooking(db, booking.id, options),
945
+ getBookingBillingContact(db, booking.id, customerRecord),
946
+ ]);
947
+ const itemLinksByItemId = new Map();
948
+ for (const link of itemParticipantLinks) {
949
+ const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
950
+ existing.push({
951
+ id: link.id,
952
+ travelerId: link.travelerId,
953
+ role: link.role,
954
+ isPrimary: link.isPrimary,
955
+ });
956
+ itemLinksByItemId.set(link.bookingItemId, existing);
957
+ }
958
+ const unifiedDocuments = [
959
+ ...documents.map((document) => ({
960
+ id: document.id,
961
+ source: "booking_document",
962
+ travelerId: document.travelerId ?? null,
963
+ type: document.type,
964
+ fileName: document.fileName,
965
+ fileUrl: document.fileUrl,
966
+ mimeType: null,
967
+ reference: null,
968
+ })),
969
+ ...legalDocuments,
970
+ ...financeData.portalDocuments,
971
+ ];
972
+ const financials = {
973
+ documents: financeData.documents,
974
+ payments: financeData.payments,
975
+ };
976
+ const travelerParticipants = participants.filter((participant) => ["traveler", "occupant", "other"].includes(participant.participantType));
977
+ return customerPortalBookingDetailSchema.parse({
978
+ bookingId: booking.id,
979
+ bookingNumber: booking.bookingNumber,
980
+ status: booking.status,
981
+ sellCurrency: booking.sellCurrency,
982
+ sellAmountCents: booking.sellAmountCents ?? null,
983
+ startDate: normalizeDate(booking.startDate),
984
+ endDate: normalizeDate(booking.endDate),
985
+ pax: booking.pax ?? null,
986
+ confirmedAt: normalizeDateTime(booking.confirmedAt),
987
+ cancelledAt: normalizeDateTime(booking.cancelledAt),
988
+ completedAt: normalizeDateTime(booking.completedAt),
989
+ travelers: travelerParticipants.map((participant) => ({
990
+ id: participant.id,
991
+ participantType: participant.participantType,
992
+ firstName: participant.firstName,
993
+ lastName: participant.lastName,
994
+ isPrimary: participant.isPrimary,
995
+ })),
996
+ items: items.map((item) => ({
997
+ id: item.id,
998
+ title: item.title,
999
+ description: item.description ?? null,
1000
+ itemType: item.itemType,
1001
+ status: item.status,
1002
+ serviceDate: normalizeDate(item.serviceDate),
1003
+ startsAt: normalizeDateTime(item.startsAt),
1004
+ endsAt: normalizeDateTime(item.endsAt),
1005
+ quantity: item.quantity,
1006
+ sellCurrency: item.sellCurrency,
1007
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
1008
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
1009
+ notes: item.notes ?? null,
1010
+ travelerLinks: itemLinksByItemId.get(item.id) ?? [],
1011
+ })),
1012
+ billingContact,
1013
+ documents: unifiedDocuments,
1014
+ financials,
1015
+ fulfillments: fulfillments.map((fulfillment) => ({
1016
+ id: fulfillment.id,
1017
+ bookingItemId: fulfillment.bookingItemId ?? null,
1018
+ travelerId: fulfillment.travelerId ?? null,
1019
+ fulfillmentType: fulfillment.fulfillmentType,
1020
+ deliveryChannel: fulfillment.deliveryChannel,
1021
+ status: fulfillment.status,
1022
+ artifactUrl: fulfillment.artifactUrl ?? null,
1023
+ })),
1024
+ });
1025
+ }
1026
+ export const publicCustomerPortalService = {
1027
+ async contactExists(db, email) {
1028
+ const normalizedEmail = normalizeEmail(email);
1029
+ const [authAccount, customerCandidates] = await Promise.all([
1030
+ db
1031
+ .select({ id: authUser.id })
1032
+ .from(authUser)
1033
+ // agent-quality: raw-sql reviewed -- owner: customer-portal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
1034
+ .where(sql `lower(${authUser.email}) = ${normalizedEmail}`)
1035
+ .limit(1),
1036
+ listCustomerRecordCandidatesByEmail(db, normalizedEmail),
1037
+ ]);
1038
+ return {
1039
+ email: normalizedEmail,
1040
+ authAccountExists: Boolean(authAccount[0]),
1041
+ customerRecordExists: customerCandidates.length > 0,
1042
+ linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
1043
+ };
1044
+ },
1045
+ async phoneContactExists(db, phone) {
1046
+ const normalizedPhone = normalizePhone(phone);
1047
+ const [authAccount, customerCandidates] = await Promise.all([
1048
+ db
1049
+ .select({
1050
+ id: authUser.id,
1051
+ phoneNumberVerified: authUser.phoneNumberVerified,
1052
+ })
1053
+ .from(authUser)
1054
+ .where(eq(authUser.phoneNumber, normalizedPhone))
1055
+ .limit(1),
1056
+ listCustomerRecordCandidatesByPhone(db, normalizedPhone),
1057
+ ]);
1058
+ return {
1059
+ phone: normalizedPhone,
1060
+ authAccountExists: Boolean(authAccount[0]),
1061
+ authAccountVerified: Boolean(authAccount[0]?.phoneNumberVerified),
1062
+ customerRecordExists: customerCandidates.length > 0,
1063
+ linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
1064
+ };
1065
+ },
1066
+ async getProfile(db, userId) {
1067
+ return this.getProfileWithOptions(db, userId);
1068
+ },
1069
+ async getProfileWithOptions(db, userId, options) {
1070
+ const [authProfile, customerRecord] = await Promise.all([
1071
+ getAuthProfileRow(db, userId),
1072
+ getCustomerRecord(db, userId),
1073
+ ]);
1074
+ if (!authProfile) {
1075
+ return null;
1076
+ }
1077
+ const linkedPerson = await getLinkedPersonPiiRow(db, userId);
1078
+ const [accessibility, dietary, loyalty, insurance] = await Promise.all([
1079
+ decryptProfileBlob(linkedPerson?.accessibilityEncrypted, options),
1080
+ decryptProfileBlob(linkedPerson?.dietaryEncrypted, options),
1081
+ decryptProfileBlob(linkedPerson?.loyaltyEncrypted, options),
1082
+ decryptProfileBlob(linkedPerson?.insuranceEncrypted, options),
1083
+ ]);
1084
+ const billingAddress = customerRecord?.billingAddress ?? null;
1085
+ return {
1086
+ userId: authProfile.id,
1087
+ email: authProfile.email,
1088
+ emailVerified: authProfile.emailVerified,
1089
+ firstName: authProfile.firstName ?? null,
1090
+ middleName: deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName),
1091
+ lastName: authProfile.lastName ?? null,
1092
+ avatarUrl: authProfile.avatarUrl ?? authProfile.image ?? null,
1093
+ locale: authProfile.locale ?? "en",
1094
+ timezone: authProfile.timezone ?? null,
1095
+ seatingPreference: authProfile.seatingPreference ?? null,
1096
+ dateOfBirth: customerRecord?.dateOfBirth ?? null,
1097
+ address: billingAddress
1098
+ ? {
1099
+ country: billingAddress.country,
1100
+ state: billingAddress.region,
1101
+ city: billingAddress.city,
1102
+ postalCode: billingAddress.postalCode,
1103
+ addressLine1: billingAddress.line1,
1104
+ addressLine2: billingAddress.line2,
1105
+ }
1106
+ : null,
1107
+ accessibility,
1108
+ dietary,
1109
+ loyalty,
1110
+ insurance,
1111
+ marketingConsent: authProfile.marketingConsent ?? false,
1112
+ marketingConsentAt: normalizeDateTime(authProfile.marketingConsentAt),
1113
+ marketingConsentSource: authProfile.marketingConsentSource ?? null,
1114
+ notificationDefaults: authProfile.notificationDefaults ?? null,
1115
+ uiPrefs: authProfile.uiPrefs ?? null,
1116
+ customerRecord,
1117
+ };
1118
+ },
1119
+ async updateProfile(db, userId, input) {
1120
+ return this.updateProfileWithOptions(db, userId, input);
1121
+ },
1122
+ async updateProfileWithOptions(db, userId, input, options) {
1123
+ const authProfile = await getAuthProfileRow(db, userId);
1124
+ if (!authProfile) {
1125
+ return { error: "not_found" };
1126
+ }
1127
+ const customerRecordId = await resolveLinkedCustomerRecordId(db, userId);
1128
+ if (input.customerRecord && !customerRecordId) {
1129
+ return { error: "customer_record_required" };
1130
+ }
1131
+ const existingMiddleName = deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName);
1132
+ const nextFirstName = input.firstName ?? authProfile.firstName ?? null;
1133
+ const nextMiddleName = input.middleName ?? existingMiddleName;
1134
+ const nextLastName = input.lastName ?? authProfile.lastName ?? null;
1135
+ const nextDisplayName = [nextFirstName, nextMiddleName, nextLastName]
1136
+ .filter(Boolean)
1137
+ .join(" ")
1138
+ .trim();
1139
+ const nextMarketingConsent = resolveMarketingConsentState({
1140
+ currentConsent: authProfile.marketingConsent,
1141
+ currentConsentAt: authProfile.marketingConsentAt,
1142
+ currentConsentSource: authProfile.marketingConsentSource,
1143
+ nextConsent: input.marketingConsent,
1144
+ nextConsentSource: input.marketingConsentSource,
1145
+ });
1146
+ const nextDateOfBirth = input.dateOfBirth !== undefined ? input.dateOfBirth : undefined;
1147
+ const nextAddressRecord = input.address !== undefined
1148
+ ? {
1149
+ billingAddress: {
1150
+ line1: input.address.addressLine1,
1151
+ line2: input.address.addressLine2,
1152
+ city: input.address.city,
1153
+ region: input.address.state,
1154
+ postalCode: input.address.postalCode,
1155
+ country: input.address.country,
1156
+ },
1157
+ }
1158
+ : undefined;
1159
+ await db
1160
+ .insert(userProfilesTable)
1161
+ .values({
1162
+ id: userId,
1163
+ firstName: nextFirstName,
1164
+ lastName: nextLastName,
1165
+ avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
1166
+ locale: input.locale ?? authProfile.locale ?? "en",
1167
+ timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
1168
+ seatingPreference: input.seatingPreference !== undefined
1169
+ ? input.seatingPreference
1170
+ : (authProfile.seatingPreference ?? null),
1171
+ marketingConsent: nextMarketingConsent.marketingConsent,
1172
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1173
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
1174
+ notificationDefaults: input.notificationDefaults !== undefined
1175
+ ? input.notificationDefaults
1176
+ : (authProfile.notificationDefaults ?? {}),
1177
+ uiPrefs: input.uiPrefs !== undefined
1178
+ ? input.uiPrefs
1179
+ : (authProfile.uiPrefs ?? {}),
1180
+ })
1181
+ .onConflictDoUpdate({
1182
+ target: userProfilesTable.id,
1183
+ set: {
1184
+ firstName: nextFirstName,
1185
+ lastName: nextLastName,
1186
+ avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
1187
+ locale: input.locale ?? authProfile.locale ?? "en",
1188
+ timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
1189
+ seatingPreference: input.seatingPreference !== undefined
1190
+ ? input.seatingPreference
1191
+ : (authProfile.seatingPreference ?? null),
1192
+ marketingConsent: nextMarketingConsent.marketingConsent,
1193
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1194
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
1195
+ notificationDefaults: input.notificationDefaults !== undefined
1196
+ ? input.notificationDefaults
1197
+ : (authProfile.notificationDefaults ?? {}),
1198
+ uiPrefs: input.uiPrefs !== undefined
1199
+ ? input.uiPrefs
1200
+ : (authProfile.uiPrefs ?? {}),
1201
+ updatedAt: new Date(),
1202
+ },
1203
+ });
1204
+ const piiUpdates = {};
1205
+ if (input.accessibility !== undefined) {
1206
+ const enc = await encryptProfileBlob(input.accessibility, options);
1207
+ if (enc !== undefined)
1208
+ piiUpdates.accessibilityEncrypted = enc;
1209
+ }
1210
+ if (input.dietary !== undefined) {
1211
+ const enc = await encryptProfileBlob(input.dietary, options);
1212
+ if (enc !== undefined)
1213
+ piiUpdates.dietaryEncrypted = enc;
1214
+ }
1215
+ if (input.loyalty !== undefined) {
1216
+ const enc = await encryptProfileBlob(input.loyalty, options);
1217
+ if (enc !== undefined)
1218
+ piiUpdates.loyaltyEncrypted = enc;
1219
+ }
1220
+ if (input.insurance !== undefined) {
1221
+ const enc = await encryptProfileBlob(input.insurance, options);
1222
+ if (enc !== undefined)
1223
+ piiUpdates.insuranceEncrypted = enc;
1224
+ }
1225
+ if (Object.keys(piiUpdates).length > 0) {
1226
+ const personId = await ensureLinkedPerson(db, userId, authProfile);
1227
+ await db
1228
+ .update(people)
1229
+ .set({ ...piiUpdates, updatedAt: new Date() })
1230
+ .where(eq(people.id, personId));
1231
+ }
1232
+ await db
1233
+ .update(authUser)
1234
+ .set({
1235
+ name: nextDisplayName || authProfile.name,
1236
+ image: input.avatarUrl !== undefined ? input.avatarUrl : (authProfile.image ?? null),
1237
+ updatedAt: new Date(),
1238
+ })
1239
+ .where(eq(authUser.id, userId));
1240
+ if (customerRecordId) {
1241
+ const nextCustomerRecord = input.customerRecord !== undefined ||
1242
+ nextDateOfBirth !== undefined ||
1243
+ nextAddressRecord !== undefined
1244
+ ? {
1245
+ ...(input.customerRecord ?? {}),
1246
+ ...(nextDateOfBirth !== undefined ? { dateOfBirth: nextDateOfBirth } : {}),
1247
+ ...(nextAddressRecord ?? {}),
1248
+ }
1249
+ : undefined;
1250
+ if (nextCustomerRecord || input.firstName !== undefined || input.lastName !== undefined) {
1251
+ if (nextCustomerRecord?.billingAddress !== undefined) {
1252
+ await upsertCustomerBillingAddress(db, customerRecordId, nextCustomerRecord.billingAddress);
1253
+ }
1254
+ await relationshipsService.updatePerson(db, customerRecordId, {
1255
+ ...(input.firstName !== undefined ? { firstName: input.firstName ?? "" } : {}),
1256
+ ...(input.lastName !== undefined ? { lastName: input.lastName ?? "" } : {}),
1257
+ ...(nextCustomerRecord?.preferredLanguage !== undefined
1258
+ ? { preferredLanguage: nextCustomerRecord.preferredLanguage }
1259
+ : {}),
1260
+ ...(nextCustomerRecord?.preferredCurrency !== undefined
1261
+ ? { preferredCurrency: nextCustomerRecord.preferredCurrency }
1262
+ : {}),
1263
+ ...(nextCustomerRecord?.dateOfBirth !== undefined
1264
+ ? { dateOfBirth: nextCustomerRecord.dateOfBirth }
1265
+ : {}),
1266
+ ...(nextCustomerRecord?.phone !== undefined ? { phone: nextCustomerRecord.phone } : {}),
1267
+ });
1268
+ }
1269
+ }
1270
+ const profile = await this.getProfileWithOptions(db, userId, options);
1271
+ if (!profile) {
1272
+ return { error: "not_found" };
1273
+ }
1274
+ return { profile };
1275
+ },
1276
+ async bootstrap(db, userId, input) {
1277
+ const authProfile = await getAuthProfileRow(db, userId);
1278
+ if (!authProfile) {
1279
+ return { error: "not_found" };
1280
+ }
1281
+ const linkedCustomerRecordId = await resolveLinkedCustomerRecordId(db, userId);
1282
+ if (linkedCustomerRecordId) {
1283
+ const profile = await this.getProfile(db, userId);
1284
+ return {
1285
+ status: "already_linked",
1286
+ profile,
1287
+ candidates: [],
1288
+ };
1289
+ }
1290
+ // Phone-only signups have no email; email-keyed candidate
1291
+ // matching simply finds zero candidates and the path falls
1292
+ // through to creating a fresh `crm.people` row when allowed.
1293
+ const normalizedEmail = authProfile.email ? normalizeEmail(authProfile.email) : null;
1294
+ const nextFirstName = input.firstName ?? authProfile.firstName ?? authProfile.name.split(" ")[0] ?? "Customer";
1295
+ const nextLastName = input.lastName ?? authProfile.lastName ?? authProfile.name.split(" ").slice(1).join(" ") ?? "";
1296
+ if (input.marketingConsent !== undefined || input.marketingConsentSource !== undefined) {
1297
+ const nextMarketingConsent = resolveMarketingConsentState({
1298
+ currentConsent: authProfile.marketingConsent,
1299
+ currentConsentAt: authProfile.marketingConsentAt,
1300
+ currentConsentSource: authProfile.marketingConsentSource,
1301
+ nextConsent: input.marketingConsent,
1302
+ nextConsentSource: input.marketingConsentSource,
1303
+ });
1304
+ await db
1305
+ .insert(userProfilesTable)
1306
+ .values({
1307
+ id: userId,
1308
+ marketingConsent: nextMarketingConsent.marketingConsent,
1309
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1310
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
1311
+ })
1312
+ .onConflictDoUpdate({
1313
+ target: userProfilesTable.id,
1314
+ set: {
1315
+ marketingConsent: nextMarketingConsent.marketingConsent,
1316
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1317
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
1318
+ updatedAt: new Date(),
1319
+ },
1320
+ });
1321
+ }
1322
+ if (input.customerRecordId) {
1323
+ const person = await relationshipsService.getPersonById(db, input.customerRecordId);
1324
+ if (!person) {
1325
+ return { error: "customer_record_not_found" };
1326
+ }
1327
+ if (person.source === linkedCustomerSource &&
1328
+ person.sourceRef &&
1329
+ person.sourceRef !== userId) {
1330
+ return { error: "customer_record_claimed" };
1331
+ }
1332
+ const updated = await relationshipsService.updatePerson(db, input.customerRecordId, {
1333
+ source: linkedCustomerSource,
1334
+ sourceRef: userId,
1335
+ ...(input.firstName !== undefined ? { firstName: nextFirstName } : {}),
1336
+ ...(input.lastName !== undefined ? { lastName: nextLastName } : {}),
1337
+ ...(input.customerRecord?.preferredLanguage !== undefined
1338
+ ? { preferredLanguage: input.customerRecord.preferredLanguage }
1339
+ : {}),
1340
+ ...(input.customerRecord?.preferredCurrency !== undefined
1341
+ ? { preferredCurrency: input.customerRecord.preferredCurrency }
1342
+ : {}),
1343
+ ...(input.customerRecord?.dateOfBirth !== undefined
1344
+ ? { dateOfBirth: input.customerRecord.dateOfBirth }
1345
+ : {}),
1346
+ ...(input.customerRecord?.phone !== undefined ? { phone: input.customerRecord.phone } : {}),
1347
+ });
1348
+ if (!updated) {
1349
+ return { error: "customer_record_not_found" };
1350
+ }
1351
+ if (input.customerRecord?.billingAddress) {
1352
+ await upsertCustomerBillingAddress(db, input.customerRecordId, input.customerRecord.billingAddress);
1353
+ }
1354
+ const profile = await this.getProfile(db, userId);
1355
+ return {
1356
+ status: "linked_existing_customer",
1357
+ profile,
1358
+ candidates: [],
1359
+ };
1360
+ }
1361
+ const customerCandidates = normalizedEmail
1362
+ ? await listCustomerRecordCandidatesByEmail(db, normalizedEmail)
1363
+ : [];
1364
+ const selectableCandidates = customerCandidates.filter((candidate) => !candidate.claimedByAnotherUser);
1365
+ if (selectableCandidates.length > 0) {
1366
+ return {
1367
+ status: "customer_selection_required",
1368
+ profile: null,
1369
+ candidates: selectableCandidates,
1370
+ };
1371
+ }
1372
+ if (!input.createCustomerIfMissing) {
1373
+ return {
1374
+ status: "customer_selection_required",
1375
+ profile: null,
1376
+ candidates: [],
1377
+ };
1378
+ }
1379
+ const created = await relationshipsService.createPerson(db, {
1380
+ firstName: nextFirstName,
1381
+ lastName: nextLastName || "Customer",
1382
+ preferredLanguage: input.customerRecord?.preferredLanguage ?? authProfile.locale ?? null,
1383
+ preferredCurrency: input.customerRecord?.preferredCurrency ?? null,
1384
+ dateOfBirth: input.customerRecord?.dateOfBirth ?? null,
1385
+ relation: "client",
1386
+ status: "active",
1387
+ source: linkedCustomerSource,
1388
+ sourceRef: userId,
1389
+ tags: [],
1390
+ email: normalizedEmail,
1391
+ phone: input.customerRecord?.phone ?? null,
1392
+ website: null,
1393
+ });
1394
+ if (!created) {
1395
+ return { error: "not_found" };
1396
+ }
1397
+ if (input.customerRecord?.billingAddress) {
1398
+ await upsertCustomerBillingAddress(db, created.id, input.customerRecord.billingAddress);
1399
+ }
1400
+ const profile = await this.getProfile(db, userId);
1401
+ return {
1402
+ status: "created_customer",
1403
+ profile,
1404
+ candidates: [],
1405
+ };
1406
+ },
1407
+ async listCompanions(db, userId) {
1408
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
1409
+ if (!personId) {
1410
+ return [];
1411
+ }
1412
+ const rows = await identityService.listNamedContactsForEntity(db, "person", personId);
1413
+ return rows
1414
+ .filter((row) => (row.metadata?.kind ?? null) ===
1415
+ companionMetadataKind)
1416
+ .map(toCustomerCompanion);
1417
+ },
1418
+ async importBookingTravelersAsCompanions(db, userId, input) {
1419
+ const authProfile = await getAuthProfileRow(db, userId);
1420
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
1421
+ if (!authProfile || !personId) {
1422
+ return null;
1423
+ }
1424
+ const accessibleBookingIds = await getAccessibleBookingIds(db, {
1425
+ userId,
1426
+ email: authProfile.email,
1427
+ });
1428
+ const targetBookingIds = input.bookingIds?.filter((bookingId) => accessibleBookingIds.includes(bookingId)) ??
1429
+ accessibleBookingIds;
1430
+ if (targetBookingIds.length === 0) {
1431
+ return { created: [], skippedCount: 0 };
1432
+ }
1433
+ const [existingCompanionRows, participantRows, staffAssignmentRows] = await Promise.all([
1434
+ identityService.listNamedContactsForEntity(db, "person", personId),
1435
+ db
1436
+ .select()
1437
+ .from(bookingTravelers)
1438
+ .where(inArray(bookingTravelers.bookingId, targetBookingIds))
1439
+ .orderBy(asc(bookingTravelers.createdAt)),
1440
+ db
1441
+ .select()
1442
+ .from(bookingStaffAssignments)
1443
+ .where(inArray(bookingStaffAssignments.bookingId, targetBookingIds))
1444
+ .orderBy(asc(bookingStaffAssignments.createdAt)),
1445
+ ]);
1446
+ const existingKeys = new Set(existingCompanionRows
1447
+ .filter((row) => (row.metadata?.kind ?? null) ===
1448
+ companionMetadataKind)
1449
+ .flatMap((row) => getCompanionLookupKeys({
1450
+ name: row.name,
1451
+ email: row.email,
1452
+ phone: row.phone,
1453
+ })));
1454
+ let skippedCount = 0;
1455
+ const created = [];
1456
+ const distinctStaffAssignmentKeys = new Set();
1457
+ for (const assignment of staffAssignmentRows) {
1458
+ distinctStaffAssignmentKeys.add(JSON.stringify([
1459
+ assignment.bookingId,
1460
+ assignment.personId ?? null,
1461
+ assignment.firstName,
1462
+ assignment.lastName,
1463
+ assignment.email ?? null,
1464
+ assignment.phone ?? null,
1465
+ ]));
1466
+ }
1467
+ skippedCount += distinctStaffAssignmentKeys.size;
1468
+ for (const participant of participantRows) {
1469
+ const name = `${participant.firstName} ${participant.lastName}`.trim();
1470
+ if (!name) {
1471
+ skippedCount += 1;
1472
+ continue;
1473
+ }
1474
+ const email = normalizeNullableString(participant.email);
1475
+ const phone = normalizeNullableString(participant.phone);
1476
+ const lookupKeys = getCompanionLookupKeys({ name, email, phone });
1477
+ if (lookupKeys.some((key) => existingKeys.has(key))) {
1478
+ skippedCount += 1;
1479
+ continue;
1480
+ }
1481
+ const row = await identityService.createNamedContact(db, {
1482
+ entityType: "person",
1483
+ entityId: personId,
1484
+ role: "general",
1485
+ name,
1486
+ title: null,
1487
+ email,
1488
+ phone,
1489
+ isPrimary: false,
1490
+ notes: normalizeNullableString(participant.notes),
1491
+ metadata: buildStoredCompanionMetadata({
1492
+ metadata: {
1493
+ source: "booking_participant_import",
1494
+ bookingId: participant.bookingId,
1495
+ travelerId: participant.id,
1496
+ participantType: participant.participantType,
1497
+ travelerCategory: participant.travelerCategory ?? null,
1498
+ },
1499
+ person: {
1500
+ firstName: participant.firstName,
1501
+ lastName: participant.lastName,
1502
+ },
1503
+ }),
1504
+ });
1505
+ if (!row) {
1506
+ skippedCount += 1;
1507
+ continue;
1508
+ }
1509
+ created.push(toCustomerCompanion(row));
1510
+ for (const key of lookupKeys) {
1511
+ existingKeys.add(key);
1512
+ }
1513
+ }
1514
+ return { created, skippedCount };
1515
+ },
1516
+ async importBookingParticipantsAsCompanions(db, userId, input) {
1517
+ return this.importBookingTravelersAsCompanions(db, userId, input);
1518
+ },
1519
+ async createCompanion(db, userId, input) {
1520
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
1521
+ if (!personId) {
1522
+ return null;
1523
+ }
1524
+ const row = await identityService.createNamedContact(db, {
1525
+ entityType: "person",
1526
+ entityId: personId,
1527
+ role: input.role,
1528
+ name: input.name,
1529
+ title: input.title ?? null,
1530
+ email: normalizeNullableString(input.email),
1531
+ phone: normalizeNullableString(input.phone),
1532
+ isPrimary: input.isPrimary,
1533
+ notes: normalizeNullableString(input.notes),
1534
+ metadata: buildStoredCompanionMetadata({
1535
+ metadata: input.metadata ?? undefined,
1536
+ typeKey: input.typeKey,
1537
+ person: input.person,
1538
+ }),
1539
+ });
1540
+ return row ? toCustomerCompanion(row) : null;
1541
+ },
1542
+ async updateCompanion(db, userId, companionId, input) {
1543
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
1544
+ if (!personId) {
1545
+ return null;
1546
+ }
1547
+ const existing = await identityService.getNamedContactById(db, companionId);
1548
+ if (!existing ||
1549
+ existing.entityType !== "person" ||
1550
+ existing.entityId !== personId ||
1551
+ (existing.metadata?.kind ?? null) !==
1552
+ companionMetadataKind) {
1553
+ return "forbidden";
1554
+ }
1555
+ const row = await identityService.updateNamedContact(db, companionId, {
1556
+ ...(input.role !== undefined ? { role: input.role } : {}),
1557
+ ...(input.name !== undefined ? { name: input.name } : {}),
1558
+ ...(input.title !== undefined ? { title: input.title } : {}),
1559
+ ...(input.email !== undefined ? { email: normalizeNullableString(input.email) } : {}),
1560
+ ...(input.phone !== undefined ? { phone: normalizeNullableString(input.phone) } : {}),
1561
+ ...(input.isPrimary !== undefined ? { isPrimary: input.isPrimary } : {}),
1562
+ ...(input.notes !== undefined ? { notes: normalizeNullableString(input.notes) } : {}),
1563
+ ...(input.metadata !== undefined || input.typeKey !== undefined || input.person !== undefined
1564
+ ? {
1565
+ metadata: buildStoredCompanionMetadata({
1566
+ existingMetadata: existing.metadata ?? undefined,
1567
+ ...(input.metadata !== undefined
1568
+ ? { metadata: input.metadata ?? null }
1569
+ : {}),
1570
+ ...(input.typeKey !== undefined ? { typeKey: input.typeKey } : {}),
1571
+ ...(input.person !== undefined ? { person: input.person } : {}),
1572
+ }),
1573
+ }
1574
+ : {}),
1575
+ });
1576
+ return row ? toCustomerCompanion(row) : null;
1577
+ },
1578
+ async deleteCompanion(db, userId, companionId) {
1579
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
1580
+ if (!personId) {
1581
+ return "not_found";
1582
+ }
1583
+ const existing = await identityService.getNamedContactById(db, companionId);
1584
+ if (!existing) {
1585
+ return "not_found";
1586
+ }
1587
+ if (existing.entityType !== "person" ||
1588
+ existing.entityId !== personId ||
1589
+ (existing.metadata?.kind ?? null) !==
1590
+ companionMetadataKind) {
1591
+ return "forbidden";
1592
+ }
1593
+ await identityService.deleteNamedContact(db, companionId);
1594
+ return "deleted";
1595
+ },
1596
+ async listBookings(db, userId) {
1597
+ const authProfile = await getAuthProfileRow(db, userId);
1598
+ if (!authProfile) {
1599
+ return null;
1600
+ }
1601
+ const bookingIds = await getAccessibleBookingIds(db, { userId, email: authProfile.email });
1602
+ if (bookingIds.length === 0) {
1603
+ return [];
1604
+ }
1605
+ const [bookingRows, participantRows, itemRows, invoiceRows] = await Promise.all([
1606
+ db
1607
+ .select()
1608
+ .from(bookings)
1609
+ .where(inArray(bookings.id, bookingIds))
1610
+ .orderBy(desc(bookings.createdAt)),
1611
+ db
1612
+ .select()
1613
+ .from(bookingTravelers)
1614
+ .where(inArray(bookingTravelers.bookingId, bookingIds))
1615
+ .orderBy(asc(bookingTravelers.createdAt)),
1616
+ db
1617
+ .select({
1618
+ bookingId: bookingItems.bookingId,
1619
+ title: bookingItems.title,
1620
+ itemType: bookingItems.itemType,
1621
+ createdAt: bookingItems.createdAt,
1622
+ })
1623
+ .from(bookingItems)
1624
+ .where(inArray(bookingItems.bookingId, bookingIds))
1625
+ .orderBy(asc(bookingItems.createdAt)),
1626
+ db
1627
+ .select({
1628
+ bookingId: invoices.bookingId,
1629
+ invoiceType: invoices.invoiceType,
1630
+ status: invoices.status,
1631
+ paidCents: invoices.paidCents,
1632
+ balanceDueCents: invoices.balanceDueCents,
1633
+ createdAt: invoices.createdAt,
1634
+ })
1635
+ .from(invoices)
1636
+ .where(inArray(invoices.bookingId, bookingIds))
1637
+ .orderBy(desc(invoices.createdAt)),
1638
+ ]);
1639
+ const participantsByBookingId = new Map();
1640
+ for (const participant of participantRows) {
1641
+ const bucket = participantsByBookingId.get(participant.bookingId) ?? [];
1642
+ bucket.push(participant);
1643
+ participantsByBookingId.set(participant.bookingId, bucket);
1644
+ }
1645
+ const itemsByBookingId = new Map();
1646
+ for (const item of itemRows) {
1647
+ const bucket = itemsByBookingId.get(item.bookingId) ?? [];
1648
+ bucket.push(item);
1649
+ itemsByBookingId.set(item.bookingId, bucket);
1650
+ }
1651
+ const invoicesByBookingId = new Map();
1652
+ for (const invoice of invoiceRows) {
1653
+ const bucket = invoicesByBookingId.get(invoice.bookingId) ?? [];
1654
+ bucket.push(invoice);
1655
+ invoicesByBookingId.set(invoice.bookingId, bucket);
1656
+ }
1657
+ return bookingRows.map((booking) => {
1658
+ const participants = participantsByBookingId.get(booking.id) ?? [];
1659
+ const items = itemsByBookingId.get(booking.id) ?? [];
1660
+ const bookingInvoices = invoicesByBookingId.get(booking.id) ?? [];
1661
+ const primaryTraveler = participants.find((participant) => participant.isPrimary) ?? participants[0] ?? null;
1662
+ return {
1663
+ bookingId: booking.id,
1664
+ bookingNumber: booking.bookingNumber,
1665
+ status: booking.status,
1666
+ sellCurrency: booking.sellCurrency,
1667
+ sellAmountCents: booking.sellAmountCents ?? null,
1668
+ productTitle: selectBookingSummaryProductTitle(items),
1669
+ paymentStatus: deriveBookingSummaryPaymentStatus(bookingInvoices, booking.sellAmountCents ?? null),
1670
+ startDate: normalizeDate(booking.startDate),
1671
+ endDate: normalizeDate(booking.endDate),
1672
+ pax: booking.pax ?? null,
1673
+ confirmedAt: normalizeDateTime(booking.confirmedAt),
1674
+ completedAt: normalizeDateTime(booking.completedAt),
1675
+ travelerCount: participants.length,
1676
+ primaryTravelerName: primaryTraveler
1677
+ ? `${primaryTraveler.firstName} ${primaryTraveler.lastName}`.trim()
1678
+ : null,
1679
+ };
1680
+ });
1681
+ },
1682
+ async getBooking(db, userId, bookingId, options = {}) {
1683
+ const authProfile = await getAuthProfileRow(db, userId);
1684
+ if (!authProfile) {
1685
+ return null;
1686
+ }
1687
+ const [linkedPersonId, customerRecord] = await Promise.all([
1688
+ resolveLinkedCustomerRecordId(db, userId),
1689
+ getCustomerRecord(db, userId),
1690
+ ]);
1691
+ const authEmail = authProfile.email?.trim().toLowerCase() ?? null;
1692
+ const canAccess = await hasBookingAccess({
1693
+ db,
1694
+ bookingId,
1695
+ userId,
1696
+ authEmail,
1697
+ linkedPersonId,
1698
+ });
1699
+ if (!canAccess) {
1700
+ return null;
1701
+ }
1702
+ return buildBookingDetail(db, bookingId, customerRecord, options);
1703
+ },
1704
+ async listBookingDocuments(db, userId, bookingId, options = {}) {
1705
+ const detail = await this.getBooking(db, userId, bookingId, options);
1706
+ return detail?.documents ?? null;
1707
+ },
1708
+ async getBookingBillingContact(db, userId, bookingId) {
1709
+ const authProfile = await getAuthProfileRow(db, userId);
1710
+ if (!authProfile) {
1711
+ return null;
1712
+ }
1713
+ const [linkedPersonId, customerRecord] = await Promise.all([
1714
+ resolveLinkedCustomerRecordId(db, userId),
1715
+ getCustomerRecord(db, userId),
1716
+ ]);
1717
+ const canAccess = await hasBookingAccess({
1718
+ db,
1719
+ bookingId,
1720
+ userId,
1721
+ authEmail: authProfile.email?.trim().toLowerCase() ?? null,
1722
+ linkedPersonId,
1723
+ });
1724
+ if (!canAccess) {
1725
+ return null;
1726
+ }
1727
+ return getBookingBillingContact(db, bookingId, customerRecord);
1728
+ },
1729
+ // ── Identity documents ────────────────────────────────────────────────
1730
+ // CRUD over `crm.person_documents` scoped to the auth user's linked
1731
+ // person. Auto-creates the linked person row on first write so
1732
+ // phone-only / metadata-light customers can save documents without
1733
+ // a separate bootstrap step.
1734
+ async listMyDocuments(db, userId, options) {
1735
+ return getLinkedPersonDocuments(db, userId, options);
1736
+ },
1737
+ async createMyDocument(db, userId, input, options) {
1738
+ const authProfile = await getAuthProfileRow(db, userId);
1739
+ if (!authProfile)
1740
+ return null;
1741
+ const personId = await ensureLinkedPerson(db, userId, authProfile);
1742
+ const numberEncrypted = await encryptDocumentNumber(input.number ?? null, options);
1743
+ const payload = {
1744
+ type: toCrmDocumentType(input.type),
1745
+ issuingAuthority: input.issuingAuthority ?? null,
1746
+ issuingCountry: input.issuingCountry ?? null,
1747
+ issueDate: input.issueDate ?? null,
1748
+ expiryDate: input.expiryDate ?? null,
1749
+ attachmentId: input.attachmentId ?? null,
1750
+ isPrimary: input.isPrimary ?? false,
1751
+ notes: input.notes ?? null,
1752
+ };
1753
+ if (numberEncrypted !== undefined) {
1754
+ payload.numberEncrypted = numberEncrypted;
1755
+ }
1756
+ const row = await relationshipsService.createPersonDocument(db, personId, payload);
1757
+ return row ? projectPersonDocumentToWire(row, options) : null;
1758
+ },
1759
+ async updateMyDocument(db, userId, documentId, input, options) {
1760
+ const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
1761
+ if (!linkedPersonId)
1762
+ return null;
1763
+ const existing = await relationshipsService.getPersonDocument(db, documentId);
1764
+ if (!existing || existing.personId !== linkedPersonId)
1765
+ return null;
1766
+ const numberEncrypted = input.number !== undefined ? await encryptDocumentNumber(input.number, options) : undefined;
1767
+ const update = {};
1768
+ if (input.type !== undefined)
1769
+ update.type = toCrmDocumentType(input.type);
1770
+ if (input.issuingAuthority !== undefined)
1771
+ update.issuingAuthority = input.issuingAuthority;
1772
+ if (input.issuingCountry !== undefined)
1773
+ update.issuingCountry = input.issuingCountry;
1774
+ if (input.issueDate !== undefined)
1775
+ update.issueDate = input.issueDate;
1776
+ if (input.expiryDate !== undefined)
1777
+ update.expiryDate = input.expiryDate;
1778
+ if (input.attachmentId !== undefined)
1779
+ update.attachmentId = input.attachmentId;
1780
+ if (input.isPrimary !== undefined)
1781
+ update.isPrimary = input.isPrimary;
1782
+ if (input.notes !== undefined)
1783
+ update.notes = input.notes;
1784
+ if (numberEncrypted !== undefined)
1785
+ update.numberEncrypted = numberEncrypted;
1786
+ const row = await relationshipsService.updatePersonDocument(db, documentId, update);
1787
+ return row ? projectPersonDocumentToWire(row, options) : null;
1788
+ },
1789
+ async deleteMyDocument(db, userId, documentId) {
1790
+ const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
1791
+ if (!linkedPersonId)
1792
+ return null;
1793
+ const existing = await relationshipsService.getPersonDocument(db, documentId);
1794
+ if (!existing || existing.personId !== linkedPersonId)
1795
+ return null;
1796
+ return relationshipsService.deletePersonDocument(db, documentId);
1797
+ },
1798
+ async setPrimaryMyDocument(db, userId, documentId, options) {
1799
+ const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
1800
+ if (!linkedPersonId)
1801
+ return null;
1802
+ const existing = await relationshipsService.getPersonDocument(db, documentId);
1803
+ if (!existing || existing.personId !== linkedPersonId)
1804
+ return null;
1805
+ const row = await relationshipsService.setPrimaryPersonDocument(db, documentId);
1806
+ return row ? projectPersonDocumentToWire(row, options) : null;
1807
+ },
1808
+ };