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