@voyantjs/customer-portal 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,41 @@
1
- import { bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingParticipants, bookings, } from "@voyantjs/bookings/schema";
1
+ import { bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingParticipants, bookingSessionStates, bookings, } from "@voyantjs/bookings/schema";
2
2
  import { crmService, people } from "@voyantjs/crm";
3
3
  import { authUser, userProfilesTable } from "@voyantjs/db/schema/iam";
4
+ import { travelDocumentSchema } from "@voyantjs/db/schema/iam/kms";
5
+ import { invoiceRenditions, invoices, payments } from "@voyantjs/finance/schema";
4
6
  import { identityContactPoints } from "@voyantjs/identity/schema";
5
7
  import { identityService } from "@voyantjs/identity/service";
8
+ import { contractAttachments, contracts } from "@voyantjs/legal/contracts/schema";
9
+ import { decryptOptionalJsonEnvelope, encryptOptionalJsonEnvelope, } from "@voyantjs/utils";
6
10
  import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
7
11
  import { customerPortalBookingDetailSchema } from "./validation-public.js";
8
12
  const linkedCustomerSource = "auth.user";
9
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
+ }
10
39
  function normalizeDate(value) {
11
40
  if (!value) {
12
41
  return null;
@@ -35,7 +64,443 @@ function normalizeEmail(value) {
35
64
  function normalizePhone(value) {
36
65
  return value.trim();
37
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 toStoredProfileDocumentType(type) {
87
+ return type === "id_card" ? "national_id" : type;
88
+ }
89
+ function toPublicProfileDocumentType(type) {
90
+ return type === "national_id" ? "id_card" : 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 formatCustomerAddress(address) {
114
+ if (address.fullText) {
115
+ return address.fullText;
116
+ }
117
+ const parts = [
118
+ address.line1,
119
+ address.line2,
120
+ address.city,
121
+ address.region,
122
+ address.postalCode,
123
+ address.country,
124
+ ].filter((value) => Boolean(value));
125
+ return parts.length > 0 ? parts.join(", ") : null;
126
+ }
127
+ function toCustomerAddress(address) {
128
+ return {
129
+ id: address.id,
130
+ label: address.label,
131
+ fullText: address.fullText ?? null,
132
+ line1: address.line1 ?? null,
133
+ line2: address.line2 ?? null,
134
+ city: address.city ?? null,
135
+ region: address.region ?? null,
136
+ postalCode: address.postalCode ?? null,
137
+ country: address.country ?? null,
138
+ isPrimary: address.isPrimary,
139
+ };
140
+ }
141
+ function getNestedRecord(record, keys) {
142
+ for (const key of keys) {
143
+ const value = getRecord(record?.[key]);
144
+ if (value) {
145
+ return value;
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+ function getRecordString(record, keys) {
151
+ for (const key of keys) {
152
+ const value = record?.[key];
153
+ if (typeof value === "string") {
154
+ const trimmed = value.trim();
155
+ if (trimmed.length > 0) {
156
+ return trimmed;
157
+ }
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ function getRecordBoolean(record, keys) {
163
+ for (const key of keys) {
164
+ const value = record?.[key];
165
+ if (typeof value === "boolean") {
166
+ return value;
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ function splitCompanionName(value) {
172
+ const parts = String(value ?? "")
173
+ .trim()
174
+ .split(/\s+/)
175
+ .filter(Boolean);
176
+ if (parts.length === 0) {
177
+ return {
178
+ firstName: null,
179
+ middleName: null,
180
+ lastName: null,
181
+ };
182
+ }
183
+ if (parts.length === 1) {
184
+ return {
185
+ firstName: parts[0] ?? null,
186
+ middleName: null,
187
+ lastName: null,
188
+ };
189
+ }
190
+ return {
191
+ firstName: parts[0] ?? null,
192
+ middleName: parts.length > 2 ? parts.slice(1, -1).join(" ") : null,
193
+ lastName: parts.at(-1) ?? null,
194
+ };
195
+ }
196
+ function normalizeCompanionAddressRecord(value) {
197
+ return {
198
+ type: getRecordString(value, ["type"]) ?? null,
199
+ country: getRecordString(value, ["country"]) ?? null,
200
+ state: getRecordString(value, ["state", "region"]) ?? null,
201
+ city: getRecordString(value, ["city"]) ?? null,
202
+ postalCode: getRecordString(value, ["postalCode", "postal"]) ?? null,
203
+ addressLine1: getRecordString(value, ["addressLine1", "line1"]) ?? null,
204
+ addressLine2: getRecordString(value, ["addressLine2", "line2"]) ?? null,
205
+ isDefault: getRecordBoolean(value, ["isDefault"]) ?? false,
206
+ };
207
+ }
208
+ function normalizeCompanionDocumentRecord(value) {
209
+ const type = getRecordString(value, ["type"]);
210
+ if (type !== "passport" &&
211
+ type !== "id_card" &&
212
+ type !== "visa" &&
213
+ type !== "drivers_license" &&
214
+ type !== "other") {
215
+ return null;
216
+ }
217
+ return {
218
+ type,
219
+ number: getRecordString(value, ["number"]) ?? null,
220
+ issuingAuthority: getRecordString(value, ["issuingAuthority"]) ?? null,
221
+ country: getRecordString(value, ["country", "issuingCountry"]) ?? null,
222
+ issueDate: getRecordString(value, ["issueDate"]) ?? null,
223
+ expiryDate: getRecordString(value, ["expiryDate"]) ?? null,
224
+ };
225
+ }
226
+ function getCompanionPersonMetadata(metadata) {
227
+ const personMetadata = getNestedRecord(metadata, ["person", "traveler", "identity"]);
228
+ const derivedName = splitCompanionName(getRecordString(metadata, ["name"]));
229
+ const addresses = Array.isArray(personMetadata?.addresses)
230
+ ? personMetadata.addresses
231
+ .map((value) => normalizeCompanionAddressRecord(getRecord(value)))
232
+ .filter(Boolean)
233
+ : [];
234
+ const documents = Array.isArray(personMetadata?.documents)
235
+ ? personMetadata.documents
236
+ .map((value) => normalizeCompanionDocumentRecord(getRecord(value)))
237
+ .filter((value) => Boolean(value))
238
+ : [];
239
+ return {
240
+ firstName: getRecordString(personMetadata, ["firstName"]) ?? derivedName.firstName,
241
+ middleName: getRecordString(personMetadata, ["middleName"]) ?? derivedName.middleName,
242
+ lastName: getRecordString(personMetadata, ["lastName"]) ?? derivedName.lastName,
243
+ dateOfBirth: getRecordString(personMetadata, ["dateOfBirth"]) ?? null,
244
+ addresses,
245
+ documents,
246
+ };
247
+ }
248
+ function getCompanionTypeKey(metadata) {
249
+ return getRecordString(metadata, ["typeKey", "relationshipType"]);
250
+ }
251
+ function buildStoredCompanionMetadata(input) {
252
+ const baseMetadata = input.metadata !== undefined
253
+ ? { ...(input.metadata ?? {}) }
254
+ : { ...(input.existingMetadata ?? {}) };
255
+ baseMetadata.kind = companionMetadataKind;
256
+ if (input.typeKey !== undefined) {
257
+ const typeKey = normalizeNullableString(input.typeKey);
258
+ if (typeKey) {
259
+ baseMetadata.typeKey = typeKey;
260
+ }
261
+ else {
262
+ delete baseMetadata.typeKey;
263
+ }
264
+ }
265
+ if (input.person !== undefined) {
266
+ baseMetadata.person = {
267
+ firstName: normalizeNullableString(input.person.firstName) ?? null,
268
+ middleName: normalizeNullableString(input.person.middleName) ?? null,
269
+ lastName: normalizeNullableString(input.person.lastName) ?? null,
270
+ dateOfBirth: normalizeNullableString(input.person.dateOfBirth) ?? null,
271
+ addresses: input.person.addresses?.map((address) => ({
272
+ type: normalizeNullableString(address.type) ?? null,
273
+ country: normalizeNullableString(address.country) ?? null,
274
+ state: normalizeNullableString(address.state) ?? null,
275
+ city: normalizeNullableString(address.city) ?? null,
276
+ postalCode: normalizeNullableString(address.postalCode) ?? null,
277
+ addressLine1: normalizeNullableString(address.addressLine1) ?? null,
278
+ addressLine2: normalizeNullableString(address.addressLine2) ?? null,
279
+ isDefault: address.isDefault ?? false,
280
+ })) ?? [],
281
+ documents: input.person.documents?.map((document) => ({
282
+ type: document.type,
283
+ number: normalizeNullableString(document.number) ?? null,
284
+ issuingAuthority: normalizeNullableString(document.issuingAuthority) ?? null,
285
+ country: normalizeNullableString(document.country) ?? null,
286
+ issueDate: normalizeNullableString(document.issueDate) ?? null,
287
+ expiryDate: normalizeNullableString(document.expiryDate) ?? null,
288
+ })) ?? [],
289
+ };
290
+ }
291
+ return baseMetadata;
292
+ }
293
+ function selectPreferredAddress(addresses) {
294
+ return (addresses.find((address) => address.label === "billing") ??
295
+ addresses.find((address) => address.isPrimary) ??
296
+ addresses[0] ??
297
+ null);
298
+ }
299
+ function resolveBillingContactFromSessionPayload(payload) {
300
+ const root = getRecord(payload);
301
+ const stepData = getNestedRecord(root, ["stepData", "steps"]);
302
+ const billingRecord = getNestedRecord(root, ["billing", "billingContact", "contact"]) ??
303
+ getNestedRecord(stepData, ["billing", "billingContact", "contact"]);
304
+ const billing = getNestedRecord(billingRecord, ["billing", "contact"]) ?? billingRecord;
305
+ if (!billing) {
306
+ return null;
307
+ }
308
+ return {
309
+ email: getRecordString(billing, ["email"]),
310
+ phone: getRecordString(billing, ["phone"]),
311
+ firstName: getRecordString(billing, ["firstName"]),
312
+ lastName: getRecordString(billing, ["lastName"]),
313
+ country: getRecordString(billing, ["country"]),
314
+ state: getRecordString(billing, ["state", "region"]),
315
+ city: getRecordString(billing, ["city"]),
316
+ address1: getRecordString(billing, ["addressLine1", "address1", "line1"]),
317
+ postal: getRecordString(billing, ["postalCode", "postal", "zip"]),
318
+ };
319
+ }
320
+ function resolveFinanceDocumentFileName(invoiceNumber, invoiceType, format) {
321
+ const extension = format ?? "pdf";
322
+ return `${invoiceType}-${invoiceNumber}.${extension}`;
323
+ }
324
+ async function listLegalDocumentsForBooking(db, bookingId) {
325
+ const contractRows = await db
326
+ .select({
327
+ id: contracts.id,
328
+ contractNumber: contracts.contractNumber,
329
+ })
330
+ .from(contracts)
331
+ .where(eq(contracts.bookingId, bookingId))
332
+ .orderBy(desc(contracts.createdAt));
333
+ if (contractRows.length === 0) {
334
+ return [];
335
+ }
336
+ const attachmentRows = await db
337
+ .select()
338
+ .from(contractAttachments)
339
+ .where(and(eq(contractAttachments.kind, "document"), or(...contractRows.map((contract) => eq(contractAttachments.contractId, contract.id)))))
340
+ .orderBy(desc(contractAttachments.createdAt));
341
+ const bestAttachmentByContractId = new Map();
342
+ for (const attachment of attachmentRows) {
343
+ const metadata = getMetadataRecord(attachment.metadata);
344
+ const downloadUrl = getMetadataString(metadata, ["url", "downloadUrl"]);
345
+ if (!downloadUrl || bestAttachmentByContractId.has(attachment.contractId)) {
346
+ continue;
347
+ }
348
+ bestAttachmentByContractId.set(attachment.contractId, attachment);
349
+ }
350
+ return contractRows.flatMap((contract) => {
351
+ const attachment = bestAttachmentByContractId.get(contract.id);
352
+ if (!attachment) {
353
+ return [];
354
+ }
355
+ const metadata = getMetadataRecord(attachment.metadata);
356
+ const downloadUrl = getMetadataString(metadata, ["url", "downloadUrl"]);
357
+ if (!downloadUrl) {
358
+ return [];
359
+ }
360
+ return [
361
+ {
362
+ id: attachment.id,
363
+ source: "legal",
364
+ participantId: null,
365
+ type: "contract",
366
+ fileName: attachment.name,
367
+ fileUrl: downloadUrl,
368
+ mimeType: attachment.mimeType ?? null,
369
+ reference: contract.contractNumber ?? null,
370
+ },
371
+ ];
372
+ });
373
+ }
374
+ function resolveFinanceDocumentDownloadUrl(metadata) {
375
+ return getMetadataString(metadata, [
376
+ "downloadUrl",
377
+ "download_url",
378
+ "signedUrl",
379
+ "signed_url",
380
+ "publicUrl",
381
+ "public_url",
382
+ "fileUrl",
383
+ "file_url",
384
+ "url",
385
+ ]);
386
+ }
387
+ function selectBookingSummaryProductTitle(items) {
388
+ const preferredItem = items.find((item) => item.itemType === "unit") ??
389
+ items.find((item) => item.itemType === "accommodation") ??
390
+ items.find((item) => item.itemType === "transport") ??
391
+ items[0] ??
392
+ null;
393
+ return preferredItem?.title ?? null;
394
+ }
395
+ function deriveBookingSummaryPaymentStatus(invoicesForBooking, fallbackSellAmountCents) {
396
+ const activeInvoices = invoicesForBooking.filter((invoice) => invoice.invoiceType !== "credit_note" && invoice.status !== "void");
397
+ if (activeInvoices.length === 0) {
398
+ return fallbackSellAmountCents && fallbackSellAmountCents > 0 ? "unpaid" : "paid";
399
+ }
400
+ if (activeInvoices.some((invoice) => invoice.status === "overdue" && invoice.balanceDueCents > 0)) {
401
+ return "overdue";
402
+ }
403
+ const totalPaidCents = activeInvoices.reduce((sum, invoice) => sum + Math.max(0, invoice.paidCents), 0);
404
+ const totalBalanceDueCents = activeInvoices.reduce((sum, invoice) => sum + Math.max(0, invoice.balanceDueCents), 0);
405
+ if (totalBalanceDueCents <= 0) {
406
+ return "paid";
407
+ }
408
+ if (totalPaidCents > 0) {
409
+ return "partially_paid";
410
+ }
411
+ return "unpaid";
412
+ }
413
+ async function getFinanceDataForBooking(db, bookingId) {
414
+ const invoiceRows = await db
415
+ .select()
416
+ .from(invoices)
417
+ .where(eq(invoices.bookingId, bookingId))
418
+ .orderBy(desc(invoices.createdAt));
419
+ if (invoiceRows.length === 0) {
420
+ return { documents: [], payments: [], portalDocuments: [] };
421
+ }
422
+ const invoiceIds = invoiceRows.map((invoice) => invoice.id);
423
+ const renditionRows = await db
424
+ .select()
425
+ .from(invoiceRenditions)
426
+ .where(inArray(invoiceRenditions.invoiceId, invoiceIds))
427
+ .orderBy(desc(invoiceRenditions.createdAt));
428
+ const paymentRows = await db
429
+ .select()
430
+ .from(payments)
431
+ .where(inArray(payments.invoiceId, invoiceIds))
432
+ .orderBy(desc(payments.paymentDate), desc(payments.createdAt));
433
+ const renditionByInvoiceId = new Map();
434
+ for (const rendition of renditionRows) {
435
+ const existing = renditionByInvoiceId.get(rendition.invoiceId) ?? [];
436
+ existing.push(rendition);
437
+ renditionByInvoiceId.set(rendition.invoiceId, existing);
438
+ }
439
+ const invoiceById = new Map(invoiceRows.map((invoice) => [invoice.id, invoice]));
440
+ const documents = invoiceRows.map((invoice) => {
441
+ const renditions = renditionByInvoiceId.get(invoice.id) ?? [];
442
+ const selectedRendition = renditions.find((rendition) => rendition.status === "ready") ?? renditions[0] ?? null;
443
+ const metadata = getMetadataRecord(selectedRendition?.metadata ?? null);
444
+ const downloadUrl = resolveFinanceDocumentDownloadUrl(metadata);
445
+ return {
446
+ invoiceId: invoice.id,
447
+ invoiceNumber: invoice.invoiceNumber,
448
+ invoiceType: invoice.invoiceType,
449
+ invoiceStatus: invoice.status,
450
+ currency: invoice.currency,
451
+ totalCents: invoice.totalCents,
452
+ paidCents: invoice.paidCents,
453
+ balanceDueCents: invoice.balanceDueCents,
454
+ issueDate: invoice.issueDate,
455
+ dueDate: invoice.dueDate,
456
+ documentStatus: selectedRendition?.status ?? "missing",
457
+ format: selectedRendition?.format ?? null,
458
+ generatedAt: normalizeDateTime(selectedRendition?.generatedAt ?? null),
459
+ downloadUrl,
460
+ };
461
+ });
462
+ const paymentHistory = paymentRows.flatMap((payment) => {
463
+ const invoice = invoiceById.get(payment.invoiceId);
464
+ if (!invoice) {
465
+ return [];
466
+ }
467
+ return [
468
+ {
469
+ id: payment.id,
470
+ invoiceId: invoice.id,
471
+ invoiceNumber: invoice.invoiceNumber,
472
+ invoiceType: invoice.invoiceType,
473
+ status: payment.status,
474
+ paymentMethod: payment.paymentMethod,
475
+ amountCents: payment.amountCents,
476
+ currency: payment.currency,
477
+ paymentDate: payment.paymentDate,
478
+ referenceNumber: payment.referenceNumber ?? null,
479
+ notes: payment.notes ?? null,
480
+ },
481
+ ];
482
+ });
483
+ const portalDocuments = documents.flatMap((document) => {
484
+ if (!document.downloadUrl) {
485
+ return [];
486
+ }
487
+ return [
488
+ {
489
+ id: document.invoiceId,
490
+ source: "finance",
491
+ participantId: null,
492
+ type: document.invoiceType,
493
+ fileName: resolveFinanceDocumentFileName(document.invoiceNumber, document.invoiceType, document.format),
494
+ fileUrl: document.downloadUrl,
495
+ mimeType: document.format === "pdf" ? "application/pdf" : null,
496
+ reference: document.invoiceNumber,
497
+ },
498
+ ];
499
+ });
500
+ return { documents, payments: paymentHistory, portalDocuments };
501
+ }
38
502
  function toCustomerCompanion(row) {
503
+ const metadata = row.metadata ?? null;
39
504
  return {
40
505
  id: row.id,
41
506
  role: row.role,
@@ -45,9 +510,24 @@ function toCustomerCompanion(row) {
45
510
  phone: row.phone ?? null,
46
511
  isPrimary: row.isPrimary,
47
512
  notes: row.notes ?? null,
48
- metadata: row.metadata ?? null,
513
+ typeKey: getCompanionTypeKey(metadata) ?? null,
514
+ person: getCompanionPersonMetadata({
515
+ ...metadata,
516
+ name: row.name,
517
+ }),
518
+ metadata,
49
519
  };
50
520
  }
521
+ function getCompanionLookupKeys(input) {
522
+ const keys = [normalizeCompanionLookupName(input.name)];
523
+ if (input.email) {
524
+ keys.push(`email:${normalizeEmail(input.email)}`);
525
+ }
526
+ if (input.phone) {
527
+ keys.push(`phone:${normalizePhone(input.phone)}`);
528
+ }
529
+ return keys;
530
+ }
51
531
  async function getAuthProfileRow(db, userId) {
52
532
  const [row] = await db
53
533
  .select({
@@ -62,8 +542,10 @@ async function getAuthProfileRow(db, userId) {
62
542
  locale: userProfilesTable.locale,
63
543
  timezone: userProfilesTable.timezone,
64
544
  seatingPreference: userProfilesTable.seatingPreference,
545
+ documentsEncrypted: userProfilesTable.documentsEncrypted,
65
546
  marketingConsent: userProfilesTable.marketingConsent,
66
547
  marketingConsentAt: userProfilesTable.marketingConsentAt,
548
+ marketingConsentSource: userProfilesTable.marketingConsentSource,
67
549
  notificationDefaults: userProfilesTable.notificationDefaults,
68
550
  uiPrefs: userProfilesTable.uiPrefs,
69
551
  })
@@ -73,6 +555,21 @@ async function getAuthProfileRow(db, userId) {
73
555
  .limit(1);
74
556
  return row ?? null;
75
557
  }
558
+ async function getProfileDocuments(authProfile, options) {
559
+ if (!authProfile?.documentsEncrypted || !options?.kms) {
560
+ return [];
561
+ }
562
+ const decrypted = (await decryptOptionalJsonEnvelope(options.kms, peopleKeyRef, authProfile.documentsEncrypted, travelDocumentSchema.array())) ?? [];
563
+ return decrypted.map((document) => ({
564
+ type: toPublicProfileDocumentType(document.type),
565
+ number: document.number,
566
+ issuingAuthority: document.issuingAuthority ?? null,
567
+ issuingCountry: document.issuingCountry,
568
+ nationality: document.nationality ?? null,
569
+ expiryDate: document.expiryDate,
570
+ issueDate: document.issueDate ?? null,
571
+ }));
572
+ }
76
573
  async function resolveLinkedCustomerRecordId(db, userId) {
77
574
  const [row] = await db
78
575
  .select({ id: people.id })
@@ -117,6 +614,7 @@ async function listCustomerRecordCandidatesByEmail(db, email) {
117
614
  address: null,
118
615
  city: null,
119
616
  country: null,
617
+ billingAddress: null,
120
618
  relation: row.relation ?? null,
121
619
  status: row.status,
122
620
  claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
@@ -160,6 +658,7 @@ async function listCustomerRecordCandidatesByPhone(db, phone) {
160
658
  address: null,
161
659
  city: null,
162
660
  country: null,
661
+ billingAddress: null,
163
662
  relation: row.relation ?? null,
164
663
  status: row.status,
165
664
  claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
@@ -171,10 +670,14 @@ async function getCustomerRecord(db, userId) {
171
670
  if (!personId) {
172
671
  return null;
173
672
  }
174
- const person = await crmService.getPersonById(db, personId);
673
+ const [person, addresses] = await Promise.all([
674
+ crmService.getPersonById(db, personId),
675
+ identityService.listAddressesForEntity(db, "person", personId),
676
+ ]);
175
677
  if (!person) {
176
678
  return null;
177
679
  }
680
+ const billingAddress = selectPreferredAddress(addresses);
178
681
  return {
179
682
  id: person.id,
180
683
  firstName: person.firstName,
@@ -187,10 +690,40 @@ async function getCustomerRecord(db, userId) {
187
690
  address: person.address ?? null,
188
691
  city: person.city ?? null,
189
692
  country: person.country ?? null,
693
+ billingAddress: billingAddress ? toCustomerAddress(billingAddress) : null,
190
694
  relation: person.relation ?? null,
191
695
  status: person.status,
192
696
  };
193
697
  }
698
+ async function upsertCustomerBillingAddress(db, personId, input, fallback) {
699
+ const existingAddresses = await identityService.listAddressesForEntity(db, "person", personId);
700
+ const existingAddress = selectPreferredAddress(existingAddresses);
701
+ const fallbackAddress = normalizeNullableString(fallback?.address);
702
+ const fallbackCity = normalizeNullableString(fallback?.city);
703
+ const fallbackCountry = normalizeNullableString(fallback?.country);
704
+ const merged = {
705
+ label: input.label ?? existingAddress?.label ?? "billing",
706
+ fullText: normalizeNullableString(input.fullText) ??
707
+ (input.line1 === undefined ? fallbackAddress : null) ??
708
+ existingAddress?.fullText ??
709
+ null,
710
+ line1: normalizeNullableString(input.line1) ?? existingAddress?.line1 ?? null,
711
+ line2: normalizeNullableString(input.line2) ?? existingAddress?.line2 ?? null,
712
+ city: normalizeNullableString(input.city) ?? fallbackCity ?? existingAddress?.city ?? null,
713
+ region: normalizeNullableString(input.region) ?? existingAddress?.region ?? null,
714
+ postalCode: normalizeNullableString(input.postalCode) ?? existingAddress?.postalCode ?? null,
715
+ country: normalizeNullableString(input.country) ?? fallbackCountry ?? existingAddress?.country ?? null,
716
+ isPrimary: input.isPrimary ?? existingAddress?.isPrimary ?? existingAddresses.length === 0,
717
+ };
718
+ if (existingAddress) {
719
+ return identityService.updateAddress(db, existingAddress.id, merged);
720
+ }
721
+ return identityService.createAddress(db, {
722
+ entityType: "person",
723
+ entityId: personId,
724
+ ...merged,
725
+ });
726
+ }
194
727
  async function getAccessibleBookingIds(db, params) {
195
728
  const linkedPersonId = await resolveLinkedCustomerRecordId(db, params.userId);
196
729
  const email = params.email.trim().toLowerCase();
@@ -214,12 +747,76 @@ async function getAccessibleBookingIds(db, params) {
214
747
  ]);
215
748
  return Array.from(new Set([...directBookingRows, ...participantPersonRows, ...participantEmailRows].map((row) => row.bookingId)));
216
749
  }
217
- async function buildBookingDetail(db, bookingId) {
750
+ async function hasBookingAccess(params) {
751
+ const ownershipConditions = [sql `lower(${bookingParticipants.email}) = ${params.authEmail}`];
752
+ if (params.linkedPersonId) {
753
+ ownershipConditions.push(eq(bookingParticipants.personId, params.linkedPersonId));
754
+ }
755
+ const [participantMatch, bookingMatch] = await Promise.all([
756
+ params.db
757
+ .select({ bookingId: bookingParticipants.bookingId })
758
+ .from(bookingParticipants)
759
+ .where(and(eq(bookingParticipants.bookingId, params.bookingId), or(...ownershipConditions)))
760
+ .limit(1),
761
+ params.linkedPersonId
762
+ ? params.db
763
+ .select({ bookingId: bookings.id })
764
+ .from(bookings)
765
+ .where(and(eq(bookings.id, params.bookingId), eq(bookings.personId, params.linkedPersonId)))
766
+ .limit(1)
767
+ : Promise.resolve([]),
768
+ ]);
769
+ return Boolean(participantMatch[0] || bookingMatch[0]);
770
+ }
771
+ async function getBookingBillingContact(db, bookingId, customerRecord) {
772
+ const [stateRows, primaryParticipantRows] = await Promise.all([
773
+ db
774
+ .select({ payload: bookingSessionStates.payload })
775
+ .from(bookingSessionStates)
776
+ .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, bookingWizardStateKey)))
777
+ .limit(1),
778
+ db
779
+ .select({
780
+ firstName: bookingParticipants.firstName,
781
+ lastName: bookingParticipants.lastName,
782
+ email: bookingParticipants.email,
783
+ phone: bookingParticipants.phone,
784
+ })
785
+ .from(bookingParticipants)
786
+ .where(and(eq(bookingParticipants.bookingId, bookingId), eq(bookingParticipants.isPrimary, true)))
787
+ .orderBy(asc(bookingParticipants.createdAt))
788
+ .limit(1),
789
+ ]);
790
+ const stateRow = stateRows[0] ?? null;
791
+ const primaryParticipant = primaryParticipantRows[0] ?? null;
792
+ const sessionBillingContact = resolveBillingContactFromSessionPayload(stateRow?.payload ?? null);
793
+ const billingAddress = customerRecord?.billingAddress ?? null;
794
+ const result = {
795
+ email: sessionBillingContact?.email ?? primaryParticipant?.email ?? customerRecord?.email ?? null,
796
+ phone: sessionBillingContact?.phone ?? primaryParticipant?.phone ?? customerRecord?.phone ?? null,
797
+ firstName: sessionBillingContact?.firstName ??
798
+ primaryParticipant?.firstName ??
799
+ customerRecord?.firstName ??
800
+ null,
801
+ lastName: sessionBillingContact?.lastName ??
802
+ primaryParticipant?.lastName ??
803
+ customerRecord?.lastName ??
804
+ null,
805
+ country: sessionBillingContact?.country ?? billingAddress?.country ?? customerRecord?.country ?? null,
806
+ state: sessionBillingContact?.state ?? billingAddress?.region ?? null,
807
+ city: sessionBillingContact?.city ?? billingAddress?.city ?? customerRecord?.city ?? null,
808
+ address1: sessionBillingContact?.address1 ?? billingAddress?.line1 ?? null,
809
+ postal: sessionBillingContact?.postal ?? billingAddress?.postalCode ?? null,
810
+ };
811
+ const hasValue = Object.values(result).some((value) => typeof value === "string" && value.length > 0);
812
+ return hasValue ? result : null;
813
+ }
814
+ async function buildBookingDetail(db, bookingId, customerRecord = null) {
218
815
  const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
219
816
  if (!booking) {
220
817
  return null;
221
818
  }
222
- const [participants, items, itemParticipantLinks, documents, fulfillments] = await Promise.all([
819
+ const [participants, items, itemParticipantLinks, documents, fulfillments, legalDocuments, financeData, billingContact,] = await Promise.all([
223
820
  db
224
821
  .select()
225
822
  .from(bookingParticipants)
@@ -252,6 +849,9 @@ async function buildBookingDetail(db, bookingId) {
252
849
  .from(bookingFulfillments)
253
850
  .where(eq(bookingFulfillments.bookingId, booking.id))
254
851
  .orderBy(asc(bookingFulfillments.createdAt)),
852
+ listLegalDocumentsForBooking(db, booking.id),
853
+ getFinanceDataForBooking(db, booking.id),
854
+ getBookingBillingContact(db, booking.id, customerRecord),
255
855
  ]);
256
856
  const itemLinksByItemId = new Map();
257
857
  for (const link of itemParticipantLinks) {
@@ -264,6 +864,24 @@ async function buildBookingDetail(db, bookingId) {
264
864
  });
265
865
  itemLinksByItemId.set(link.bookingItemId, existing);
266
866
  }
867
+ const unifiedDocuments = [
868
+ ...documents.map((document) => ({
869
+ id: document.id,
870
+ source: "booking_document",
871
+ participantId: document.participantId ?? null,
872
+ type: document.type,
873
+ fileName: document.fileName,
874
+ fileUrl: document.fileUrl,
875
+ mimeType: null,
876
+ reference: null,
877
+ })),
878
+ ...legalDocuments,
879
+ ...financeData.portalDocuments,
880
+ ];
881
+ const financials = {
882
+ documents: financeData.documents,
883
+ payments: financeData.payments,
884
+ };
267
885
  return customerPortalBookingDetailSchema.parse({
268
886
  bookingId: booking.id,
269
887
  bookingNumber: booking.bookingNumber,
@@ -299,13 +917,9 @@ async function buildBookingDetail(db, bookingId) {
299
917
  notes: item.notes ?? null,
300
918
  participantLinks: itemLinksByItemId.get(item.id) ?? [],
301
919
  })),
302
- documents: documents.map((document) => ({
303
- id: document.id,
304
- participantId: document.participantId ?? null,
305
- type: document.type,
306
- fileName: document.fileName,
307
- fileUrl: document.fileUrl,
308
- })),
920
+ billingContact,
921
+ documents: unifiedDocuments,
922
+ financials,
309
923
  fulfillments: fulfillments.map((fulfillment) => ({
310
924
  id: fulfillment.id,
311
925
  bookingItemId: fulfillment.bookingItemId ?? null,
@@ -345,6 +959,9 @@ export const publicCustomerPortalService = {
345
959
  };
346
960
  },
347
961
  async getProfile(db, userId) {
962
+ return this.getProfileWithOptions(db, userId);
963
+ },
964
+ async getProfileWithOptions(db, userId, options) {
348
965
  const [authProfile, customerRecord] = await Promise.all([
349
966
  getAuthProfileRow(db, userId),
350
967
  getCustomerRecord(db, userId),
@@ -352,24 +969,43 @@ export const publicCustomerPortalService = {
352
969
  if (!authProfile) {
353
970
  return null;
354
971
  }
972
+ const documents = await getProfileDocuments(authProfile, options);
973
+ const billingAddress = customerRecord?.billingAddress ?? null;
355
974
  return {
356
975
  userId: authProfile.id,
357
976
  email: authProfile.email,
358
977
  emailVerified: authProfile.emailVerified,
359
978
  firstName: authProfile.firstName ?? null,
979
+ middleName: deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName),
360
980
  lastName: authProfile.lastName ?? null,
361
981
  avatarUrl: authProfile.avatarUrl ?? authProfile.image ?? null,
362
982
  locale: authProfile.locale ?? "en",
363
983
  timezone: authProfile.timezone ?? null,
364
984
  seatingPreference: authProfile.seatingPreference ?? null,
985
+ dateOfBirth: customerRecord?.birthday ?? null,
986
+ address: billingAddress
987
+ ? {
988
+ country: billingAddress.country,
989
+ state: billingAddress.region,
990
+ city: billingAddress.city,
991
+ postalCode: billingAddress.postalCode,
992
+ addressLine1: billingAddress.line1,
993
+ addressLine2: billingAddress.line2,
994
+ }
995
+ : null,
996
+ documents,
365
997
  marketingConsent: authProfile.marketingConsent ?? false,
366
998
  marketingConsentAt: normalizeDateTime(authProfile.marketingConsentAt),
999
+ marketingConsentSource: authProfile.marketingConsentSource ?? null,
367
1000
  notificationDefaults: authProfile.notificationDefaults ?? null,
368
1001
  uiPrefs: authProfile.uiPrefs ?? null,
369
1002
  customerRecord,
370
1003
  };
371
1004
  },
372
1005
  async updateProfile(db, userId, input) {
1006
+ return this.updateProfileWithOptions(db, userId, input);
1007
+ },
1008
+ async updateProfileWithOptions(db, userId, input, options) {
373
1009
  const authProfile = await getAuthProfileRow(db, userId);
374
1010
  if (!authProfile) {
375
1011
  return { error: "not_found" };
@@ -378,31 +1014,63 @@ export const publicCustomerPortalService = {
378
1014
  if (input.customerRecord && !customerRecordId) {
379
1015
  return { error: "customer_record_required" };
380
1016
  }
1017
+ const existingMiddleName = deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName);
381
1018
  const nextFirstName = input.firstName ?? authProfile.firstName ?? null;
1019
+ const nextMiddleName = input.middleName ?? existingMiddleName;
382
1020
  const nextLastName = input.lastName ?? authProfile.lastName ?? null;
383
- const nextDisplayName = [nextFirstName, nextLastName].filter(Boolean).join(" ").trim();
1021
+ const nextDisplayName = [nextFirstName, nextMiddleName, nextLastName]
1022
+ .filter(Boolean)
1023
+ .join(" ")
1024
+ .trim();
1025
+ const nextMarketingConsent = resolveMarketingConsentState({
1026
+ currentConsent: authProfile.marketingConsent,
1027
+ currentConsentAt: authProfile.marketingConsentAt,
1028
+ currentConsentSource: authProfile.marketingConsentSource,
1029
+ nextConsent: input.marketingConsent,
1030
+ nextConsentSource: input.marketingConsentSource,
1031
+ });
1032
+ const nextDateOfBirth = input.dateOfBirth !== undefined ? input.dateOfBirth : undefined;
1033
+ const nextAddressRecord = input.address !== undefined
1034
+ ? {
1035
+ city: input.address.city,
1036
+ country: input.address.country,
1037
+ billingAddress: {
1038
+ line1: input.address.addressLine1,
1039
+ line2: input.address.addressLine2,
1040
+ city: input.address.city,
1041
+ region: input.address.state,
1042
+ postalCode: input.address.postalCode,
1043
+ country: input.address.country,
1044
+ },
1045
+ }
1046
+ : undefined;
1047
+ const documentsEncrypted = input.documents !== undefined && options?.kms
1048
+ ? await encryptOptionalJsonEnvelope(options.kms, peopleKeyRef, input.documents.map((document) => ({
1049
+ type: toStoredProfileDocumentType(document.type),
1050
+ number: document.number,
1051
+ issuingAuthority: document.issuingAuthority ?? undefined,
1052
+ issuingCountry: document.issuingCountry,
1053
+ nationality: document.nationality ?? undefined,
1054
+ expiryDate: document.expiryDate,
1055
+ issueDate: document.issueDate ?? undefined,
1056
+ })))
1057
+ : undefined;
384
1058
  await db
385
1059
  .insert(userProfilesTable)
386
1060
  .values({
387
1061
  id: userId,
388
1062
  firstName: nextFirstName,
389
1063
  lastName: nextLastName,
1064
+ ...(documentsEncrypted !== undefined ? { documentsEncrypted } : {}),
390
1065
  avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
391
1066
  locale: input.locale ?? authProfile.locale ?? "en",
392
1067
  timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
393
1068
  seatingPreference: input.seatingPreference !== undefined
394
1069
  ? input.seatingPreference
395
1070
  : (authProfile.seatingPreference ?? null),
396
- marketingConsent: input.marketingConsent !== undefined
397
- ? input.marketingConsent
398
- : (authProfile.marketingConsent ?? false),
399
- marketingConsentAt: input.marketingConsent === undefined
400
- ? (authProfile.marketingConsentAt ?? null)
401
- : input.marketingConsent
402
- ? authProfile.marketingConsent
403
- ? (authProfile.marketingConsentAt ?? new Date())
404
- : new Date()
405
- : null,
1071
+ marketingConsent: nextMarketingConsent.marketingConsent,
1072
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1073
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
406
1074
  notificationDefaults: input.notificationDefaults !== undefined
407
1075
  ? input.notificationDefaults
408
1076
  : (authProfile.notificationDefaults ?? {}),
@@ -415,22 +1083,16 @@ export const publicCustomerPortalService = {
415
1083
  set: {
416
1084
  firstName: nextFirstName,
417
1085
  lastName: nextLastName,
1086
+ ...(documentsEncrypted !== undefined ? { documentsEncrypted } : {}),
418
1087
  avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
419
1088
  locale: input.locale ?? authProfile.locale ?? "en",
420
1089
  timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
421
1090
  seatingPreference: input.seatingPreference !== undefined
422
1091
  ? input.seatingPreference
423
1092
  : (authProfile.seatingPreference ?? null),
424
- marketingConsent: input.marketingConsent !== undefined
425
- ? input.marketingConsent
426
- : (authProfile.marketingConsent ?? false),
427
- marketingConsentAt: input.marketingConsent === undefined
428
- ? (authProfile.marketingConsentAt ?? null)
429
- : input.marketingConsent
430
- ? authProfile.marketingConsent
431
- ? (authProfile.marketingConsentAt ?? new Date())
432
- : new Date()
433
- : null,
1093
+ marketingConsent: nextMarketingConsent.marketingConsent,
1094
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1095
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
434
1096
  notificationDefaults: input.notificationDefaults !== undefined
435
1097
  ? input.notificationDefaults
436
1098
  : (authProfile.notificationDefaults ?? {}),
@@ -449,8 +1111,23 @@ export const publicCustomerPortalService = {
449
1111
  })
450
1112
  .where(eq(authUser.id, userId));
451
1113
  if (customerRecordId) {
452
- const nextCustomerRecord = input.customerRecord;
1114
+ const nextCustomerRecord = input.customerRecord !== undefined ||
1115
+ nextDateOfBirth !== undefined ||
1116
+ nextAddressRecord !== undefined
1117
+ ? {
1118
+ ...(input.customerRecord ?? {}),
1119
+ ...(nextDateOfBirth !== undefined ? { birthday: nextDateOfBirth } : {}),
1120
+ ...(nextAddressRecord ?? {}),
1121
+ }
1122
+ : undefined;
453
1123
  if (nextCustomerRecord || input.firstName !== undefined || input.lastName !== undefined) {
1124
+ const billingAddress = nextCustomerRecord?.billingAddress !== undefined
1125
+ ? await upsertCustomerBillingAddress(db, customerRecordId, nextCustomerRecord.billingAddress, {
1126
+ address: nextCustomerRecord.address,
1127
+ city: nextCustomerRecord.city,
1128
+ country: nextCustomerRecord.country,
1129
+ })
1130
+ : null;
454
1131
  await crmService.updatePerson(db, customerRecordId, {
455
1132
  ...(input.firstName !== undefined ? { firstName: input.firstName ?? "" } : {}),
456
1133
  ...(input.lastName !== undefined ? { lastName: input.lastName ?? "" } : {}),
@@ -471,10 +1148,27 @@ export const publicCustomerPortalService = {
471
1148
  ...(nextCustomerRecord?.country !== undefined
472
1149
  ? { country: nextCustomerRecord.country }
473
1150
  : {}),
1151
+ ...(nextCustomerRecord?.billingAddress !== undefined
1152
+ ? {
1153
+ address: nextCustomerRecord.address !== undefined
1154
+ ? nextCustomerRecord.address
1155
+ : formatCustomerAddress(billingAddress ?? nextCustomerRecord.billingAddress),
1156
+ city: nextCustomerRecord.city !== undefined
1157
+ ? nextCustomerRecord.city
1158
+ : (normalizeNullableString(nextCustomerRecord.billingAddress.city) ??
1159
+ billingAddress?.city ??
1160
+ null),
1161
+ country: nextCustomerRecord.country !== undefined
1162
+ ? nextCustomerRecord.country
1163
+ : (normalizeNullableString(nextCustomerRecord.billingAddress.country) ??
1164
+ billingAddress?.country ??
1165
+ null),
1166
+ }
1167
+ : {}),
474
1168
  });
475
1169
  }
476
1170
  }
477
- const profile = await this.getProfile(db, userId);
1171
+ const profile = await this.getProfileWithOptions(db, userId, options);
478
1172
  if (!profile) {
479
1173
  return { error: "not_found" };
480
1174
  }
@@ -497,19 +1191,28 @@ export const publicCustomerPortalService = {
497
1191
  const normalizedEmail = normalizeEmail(authProfile.email);
498
1192
  const nextFirstName = input.firstName ?? authProfile.firstName ?? authProfile.name.split(" ")[0] ?? "Customer";
499
1193
  const nextLastName = input.lastName ?? authProfile.lastName ?? authProfile.name.split(" ").slice(1).join(" ") ?? "";
500
- if (input.marketingConsent !== undefined) {
1194
+ if (input.marketingConsent !== undefined || input.marketingConsentSource !== undefined) {
1195
+ const nextMarketingConsent = resolveMarketingConsentState({
1196
+ currentConsent: authProfile.marketingConsent,
1197
+ currentConsentAt: authProfile.marketingConsentAt,
1198
+ currentConsentSource: authProfile.marketingConsentSource,
1199
+ nextConsent: input.marketingConsent,
1200
+ nextConsentSource: input.marketingConsentSource,
1201
+ });
501
1202
  await db
502
1203
  .insert(userProfilesTable)
503
1204
  .values({
504
1205
  id: userId,
505
- marketingConsent: input.marketingConsent,
506
- marketingConsentAt: input.marketingConsent ? new Date() : null,
1206
+ marketingConsent: nextMarketingConsent.marketingConsent,
1207
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1208
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
507
1209
  })
508
1210
  .onConflictDoUpdate({
509
1211
  target: userProfilesTable.id,
510
1212
  set: {
511
- marketingConsent: input.marketingConsent,
512
- marketingConsentAt: input.marketingConsent ? new Date() : null,
1213
+ marketingConsent: nextMarketingConsent.marketingConsent,
1214
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1215
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
513
1216
  updatedAt: new Date(),
514
1217
  },
515
1218
  });
@@ -546,10 +1249,30 @@ export const publicCustomerPortalService = {
546
1249
  ...(input.customerRecord?.country !== undefined
547
1250
  ? { country: input.customerRecord.country }
548
1251
  : {}),
1252
+ ...(input.customerRecord?.billingAddress !== undefined
1253
+ ? {
1254
+ address: input.customerRecord.address !== undefined
1255
+ ? input.customerRecord.address
1256
+ : formatCustomerAddress(input.customerRecord.billingAddress),
1257
+ city: input.customerRecord.city !== undefined
1258
+ ? input.customerRecord.city
1259
+ : (normalizeNullableString(input.customerRecord.billingAddress.city) ?? null),
1260
+ country: input.customerRecord.country !== undefined
1261
+ ? input.customerRecord.country
1262
+ : (normalizeNullableString(input.customerRecord.billingAddress.country) ?? null),
1263
+ }
1264
+ : {}),
549
1265
  });
550
1266
  if (!updated) {
551
1267
  return { error: "customer_record_not_found" };
552
1268
  }
1269
+ if (input.customerRecord?.billingAddress) {
1270
+ await upsertCustomerBillingAddress(db, input.customerRecordId, input.customerRecord.billingAddress, {
1271
+ address: input.customerRecord.address,
1272
+ city: input.customerRecord.city,
1273
+ country: input.customerRecord.country,
1274
+ });
1275
+ }
553
1276
  const profile = await this.getProfile(db, userId);
554
1277
  return {
555
1278
  status: "linked_existing_customer",
@@ -587,13 +1310,32 @@ export const publicCustomerPortalService = {
587
1310
  email: normalizedEmail,
588
1311
  phone: input.customerRecord?.phone ?? null,
589
1312
  website: null,
590
- address: input.customerRecord?.address ?? null,
591
- city: input.customerRecord?.city ?? null,
592
- country: input.customerRecord?.country ?? null,
1313
+ address: input.customerRecord?.billingAddress !== undefined
1314
+ ? input.customerRecord.address !== undefined
1315
+ ? input.customerRecord.address
1316
+ : formatCustomerAddress(input.customerRecord.billingAddress)
1317
+ : (input.customerRecord?.address ?? null),
1318
+ city: input.customerRecord?.billingAddress !== undefined
1319
+ ? input.customerRecord.city !== undefined
1320
+ ? input.customerRecord.city
1321
+ : (normalizeNullableString(input.customerRecord.billingAddress.city) ?? null)
1322
+ : (input.customerRecord?.city ?? null),
1323
+ country: input.customerRecord?.billingAddress !== undefined
1324
+ ? input.customerRecord.country !== undefined
1325
+ ? input.customerRecord.country
1326
+ : (normalizeNullableString(input.customerRecord.billingAddress.country) ?? null)
1327
+ : (input.customerRecord?.country ?? null),
593
1328
  });
594
1329
  if (!created) {
595
1330
  return { error: "not_found" };
596
1331
  }
1332
+ if (input.customerRecord?.billingAddress) {
1333
+ await upsertCustomerBillingAddress(db, created.id, input.customerRecord.billingAddress, {
1334
+ address: input.customerRecord.address,
1335
+ city: input.customerRecord.city,
1336
+ country: input.customerRecord.country,
1337
+ });
1338
+ }
597
1339
  const profile = await this.getProfile(db, userId);
598
1340
  return {
599
1341
  status: "created_customer",
@@ -612,6 +1354,91 @@ export const publicCustomerPortalService = {
612
1354
  companionMetadataKind)
613
1355
  .map(toCustomerCompanion);
614
1356
  },
1357
+ async importBookingParticipantsAsCompanions(db, userId, input) {
1358
+ const authProfile = await getAuthProfileRow(db, userId);
1359
+ const personId = await resolveLinkedCustomerRecordId(db, userId);
1360
+ if (!authProfile || !personId) {
1361
+ return null;
1362
+ }
1363
+ const accessibleBookingIds = await getAccessibleBookingIds(db, {
1364
+ userId,
1365
+ email: authProfile.email,
1366
+ });
1367
+ const targetBookingIds = input.bookingIds?.filter((bookingId) => accessibleBookingIds.includes(bookingId)) ??
1368
+ accessibleBookingIds;
1369
+ if (targetBookingIds.length === 0) {
1370
+ return { created: [], skippedCount: 0 };
1371
+ }
1372
+ const [existingCompanionRows, participantRows] = await Promise.all([
1373
+ identityService.listNamedContactsForEntity(db, "person", personId),
1374
+ db
1375
+ .select()
1376
+ .from(bookingParticipants)
1377
+ .where(inArray(bookingParticipants.bookingId, targetBookingIds))
1378
+ .orderBy(asc(bookingParticipants.createdAt)),
1379
+ ]);
1380
+ const existingKeys = new Set(existingCompanionRows
1381
+ .filter((row) => (row.metadata?.kind ?? null) ===
1382
+ companionMetadataKind)
1383
+ .flatMap((row) => getCompanionLookupKeys({
1384
+ name: row.name,
1385
+ email: row.email,
1386
+ phone: row.phone,
1387
+ })));
1388
+ let skippedCount = 0;
1389
+ const created = [];
1390
+ for (const participant of participantRows) {
1391
+ if (participant.participantType === "staff") {
1392
+ skippedCount += 1;
1393
+ continue;
1394
+ }
1395
+ const name = `${participant.firstName} ${participant.lastName}`.trim();
1396
+ if (!name) {
1397
+ skippedCount += 1;
1398
+ continue;
1399
+ }
1400
+ const email = normalizeNullableString(participant.email);
1401
+ const phone = normalizeNullableString(participant.phone);
1402
+ const lookupKeys = getCompanionLookupKeys({ name, email, phone });
1403
+ if (lookupKeys.some((key) => existingKeys.has(key))) {
1404
+ skippedCount += 1;
1405
+ continue;
1406
+ }
1407
+ const row = await identityService.createNamedContact(db, {
1408
+ entityType: "person",
1409
+ entityId: personId,
1410
+ role: "general",
1411
+ name,
1412
+ title: null,
1413
+ email,
1414
+ phone,
1415
+ isPrimary: false,
1416
+ notes: normalizeNullableString(participant.notes),
1417
+ metadata: buildStoredCompanionMetadata({
1418
+ metadata: {
1419
+ source: "booking_participant_import",
1420
+ bookingId: participant.bookingId,
1421
+ participantId: participant.id,
1422
+ participantType: participant.participantType,
1423
+ travelerCategory: participant.travelerCategory ?? null,
1424
+ },
1425
+ person: {
1426
+ firstName: participant.firstName,
1427
+ lastName: participant.lastName,
1428
+ },
1429
+ }),
1430
+ });
1431
+ if (!row) {
1432
+ skippedCount += 1;
1433
+ continue;
1434
+ }
1435
+ created.push(toCustomerCompanion(row));
1436
+ for (const key of lookupKeys) {
1437
+ existingKeys.add(key);
1438
+ }
1439
+ }
1440
+ return { created, skippedCount };
1441
+ },
615
1442
  async createCompanion(db, userId, input) {
616
1443
  const personId = await resolveLinkedCustomerRecordId(db, userId);
617
1444
  if (!personId) {
@@ -627,10 +1454,11 @@ export const publicCustomerPortalService = {
627
1454
  phone: normalizeNullableString(input.phone),
628
1455
  isPrimary: input.isPrimary,
629
1456
  notes: normalizeNullableString(input.notes),
630
- metadata: {
631
- kind: companionMetadataKind,
632
- ...(input.metadata ?? {}),
633
- },
1457
+ metadata: buildStoredCompanionMetadata({
1458
+ metadata: input.metadata ?? undefined,
1459
+ typeKey: input.typeKey,
1460
+ person: input.person,
1461
+ }),
634
1462
  });
635
1463
  return row ? toCustomerCompanion(row) : null;
636
1464
  },
@@ -655,12 +1483,16 @@ export const publicCustomerPortalService = {
655
1483
  ...(input.phone !== undefined ? { phone: normalizeNullableString(input.phone) } : {}),
656
1484
  ...(input.isPrimary !== undefined ? { isPrimary: input.isPrimary } : {}),
657
1485
  ...(input.notes !== undefined ? { notes: normalizeNullableString(input.notes) } : {}),
658
- ...(input.metadata !== undefined
1486
+ ...(input.metadata !== undefined || input.typeKey !== undefined || input.person !== undefined
659
1487
  ? {
660
- metadata: {
661
- kind: companionMetadataKind,
662
- ...(input.metadata ?? {}),
663
- },
1488
+ metadata: buildStoredCompanionMetadata({
1489
+ existingMetadata: existing.metadata ?? undefined,
1490
+ ...(input.metadata !== undefined
1491
+ ? { metadata: input.metadata ?? null }
1492
+ : {}),
1493
+ ...(input.typeKey !== undefined ? { typeKey: input.typeKey } : {}),
1494
+ ...(input.person !== undefined ? { person: input.person } : {}),
1495
+ }),
664
1496
  }
665
1497
  : {}),
666
1498
  });
@@ -693,7 +1525,7 @@ export const publicCustomerPortalService = {
693
1525
  if (bookingIds.length === 0) {
694
1526
  return [];
695
1527
  }
696
- const [bookingRows, participantRows] = await Promise.all([
1528
+ const [bookingRows, participantRows, itemRows, invoiceRows] = await Promise.all([
697
1529
  db
698
1530
  .select()
699
1531
  .from(bookings)
@@ -704,6 +1536,28 @@ export const publicCustomerPortalService = {
704
1536
  .from(bookingParticipants)
705
1537
  .where(inArray(bookingParticipants.bookingId, bookingIds))
706
1538
  .orderBy(asc(bookingParticipants.createdAt)),
1539
+ db
1540
+ .select({
1541
+ bookingId: bookingItems.bookingId,
1542
+ title: bookingItems.title,
1543
+ itemType: bookingItems.itemType,
1544
+ createdAt: bookingItems.createdAt,
1545
+ })
1546
+ .from(bookingItems)
1547
+ .where(inArray(bookingItems.bookingId, bookingIds))
1548
+ .orderBy(asc(bookingItems.createdAt)),
1549
+ db
1550
+ .select({
1551
+ bookingId: invoices.bookingId,
1552
+ invoiceType: invoices.invoiceType,
1553
+ status: invoices.status,
1554
+ paidCents: invoices.paidCents,
1555
+ balanceDueCents: invoices.balanceDueCents,
1556
+ createdAt: invoices.createdAt,
1557
+ })
1558
+ .from(invoices)
1559
+ .where(inArray(invoices.bookingId, bookingIds))
1560
+ .orderBy(desc(invoices.createdAt)),
707
1561
  ]);
708
1562
  const participantsByBookingId = new Map();
709
1563
  for (const participant of participantRows) {
@@ -711,8 +1565,22 @@ export const publicCustomerPortalService = {
711
1565
  bucket.push(participant);
712
1566
  participantsByBookingId.set(participant.bookingId, bucket);
713
1567
  }
1568
+ const itemsByBookingId = new Map();
1569
+ for (const item of itemRows) {
1570
+ const bucket = itemsByBookingId.get(item.bookingId) ?? [];
1571
+ bucket.push(item);
1572
+ itemsByBookingId.set(item.bookingId, bucket);
1573
+ }
1574
+ const invoicesByBookingId = new Map();
1575
+ for (const invoice of invoiceRows) {
1576
+ const bucket = invoicesByBookingId.get(invoice.bookingId) ?? [];
1577
+ bucket.push(invoice);
1578
+ invoicesByBookingId.set(invoice.bookingId, bucket);
1579
+ }
714
1580
  return bookingRows.map((booking) => {
715
1581
  const participants = participantsByBookingId.get(booking.id) ?? [];
1582
+ const items = itemsByBookingId.get(booking.id) ?? [];
1583
+ const bookingInvoices = invoicesByBookingId.get(booking.id) ?? [];
716
1584
  const primaryTraveler = participants.find((participant) => participant.isPrimary) ?? participants[0] ?? null;
717
1585
  return {
718
1586
  bookingId: booking.id,
@@ -720,6 +1588,8 @@ export const publicCustomerPortalService = {
720
1588
  status: booking.status,
721
1589
  sellCurrency: booking.sellCurrency,
722
1590
  sellAmountCents: booking.sellAmountCents ?? null,
1591
+ productTitle: selectBookingSummaryProductTitle(items),
1592
+ paymentStatus: deriveBookingSummaryPaymentStatus(bookingInvoices, booking.sellAmountCents ?? null),
723
1593
  startDate: normalizeDate(booking.startDate),
724
1594
  endDate: normalizeDate(booking.endDate),
725
1595
  pax: booking.pax ?? null,
@@ -737,33 +1607,46 @@ export const publicCustomerPortalService = {
737
1607
  if (!authProfile) {
738
1608
  return null;
739
1609
  }
740
- const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
741
- const authEmail = authProfile.email.trim().toLowerCase();
742
- const ownershipConditions = [sql `lower(${bookingParticipants.email}) = ${authEmail}`];
743
- if (linkedPersonId) {
744
- ownershipConditions.push(eq(bookingParticipants.personId, linkedPersonId));
745
- }
746
- const [participantMatch, bookingMatch] = await Promise.all([
747
- db
748
- .select({ bookingId: bookingParticipants.bookingId })
749
- .from(bookingParticipants)
750
- .where(and(eq(bookingParticipants.bookingId, bookingId), or(...ownershipConditions)))
751
- .limit(1),
752
- linkedPersonId
753
- ? db
754
- .select({ bookingId: bookings.id })
755
- .from(bookings)
756
- .where(and(eq(bookings.id, bookingId), eq(bookings.personId, linkedPersonId)))
757
- .limit(1)
758
- : Promise.resolve([]),
1610
+ const [linkedPersonId, customerRecord] = await Promise.all([
1611
+ resolveLinkedCustomerRecordId(db, userId),
1612
+ getCustomerRecord(db, userId),
759
1613
  ]);
760
- if (!participantMatch[0] && !bookingMatch[0]) {
1614
+ const authEmail = authProfile.email.trim().toLowerCase();
1615
+ const canAccess = await hasBookingAccess({
1616
+ db,
1617
+ bookingId,
1618
+ userId,
1619
+ authEmail,
1620
+ linkedPersonId,
1621
+ });
1622
+ if (!canAccess) {
761
1623
  return null;
762
1624
  }
763
- return buildBookingDetail(db, bookingId);
1625
+ return buildBookingDetail(db, bookingId, customerRecord);
764
1626
  },
765
1627
  async listBookingDocuments(db, userId, bookingId) {
766
1628
  const detail = await this.getBooking(db, userId, bookingId);
767
1629
  return detail?.documents ?? null;
768
1630
  },
1631
+ async getBookingBillingContact(db, userId, bookingId) {
1632
+ const authProfile = await getAuthProfileRow(db, userId);
1633
+ if (!authProfile) {
1634
+ return null;
1635
+ }
1636
+ const [linkedPersonId, customerRecord] = await Promise.all([
1637
+ resolveLinkedCustomerRecordId(db, userId),
1638
+ getCustomerRecord(db, userId),
1639
+ ]);
1640
+ const canAccess = await hasBookingAccess({
1641
+ db,
1642
+ bookingId,
1643
+ userId,
1644
+ authEmail: authProfile.email.trim().toLowerCase(),
1645
+ linkedPersonId,
1646
+ });
1647
+ if (!canAccess) {
1648
+ return null;
1649
+ }
1650
+ return getBookingBillingContact(db, bookingId, customerRecord);
1651
+ },
769
1652
  };