@voyantjs/customer-portal 0.3.0 → 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;
@@ -32,7 +61,446 @@ function normalizeNullableString(value) {
32
61
  function normalizeEmail(value) {
33
62
  return value.trim().toLowerCase();
34
63
  }
64
+ function normalizePhone(value) {
65
+ return value.trim();
66
+ }
67
+ function normalizeCompanionLookupName(value) {
68
+ return value.trim().toLowerCase();
69
+ }
70
+ function deriveMiddleName(fullName, firstName, lastName) {
71
+ const normalizedFullName = fullName?.trim() ?? "";
72
+ if (!normalizedFullName) {
73
+ return null;
74
+ }
75
+ const normalizedFirstName = firstName?.trim() ?? "";
76
+ const normalizedLastName = lastName?.trim() ?? "";
77
+ let working = normalizedFullName;
78
+ if (normalizedFirstName && working.toLowerCase().startsWith(normalizedFirstName.toLowerCase())) {
79
+ working = working.slice(normalizedFirstName.length).trim();
80
+ }
81
+ if (normalizedLastName && working.toLowerCase().endsWith(normalizedLastName.toLowerCase())) {
82
+ working = working.slice(0, -normalizedLastName.length).trim();
83
+ }
84
+ return working.length > 0 ? working : null;
85
+ }
86
+ function 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
+ }
35
502
  function toCustomerCompanion(row) {
503
+ const metadata = row.metadata ?? null;
36
504
  return {
37
505
  id: row.id,
38
506
  role: row.role,
@@ -42,9 +510,24 @@ function toCustomerCompanion(row) {
42
510
  phone: row.phone ?? null,
43
511
  isPrimary: row.isPrimary,
44
512
  notes: row.notes ?? null,
45
- metadata: row.metadata ?? null,
513
+ typeKey: getCompanionTypeKey(metadata) ?? null,
514
+ person: getCompanionPersonMetadata({
515
+ ...metadata,
516
+ name: row.name,
517
+ }),
518
+ metadata,
46
519
  };
47
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
+ }
48
531
  async function getAuthProfileRow(db, userId) {
49
532
  const [row] = await db
50
533
  .select({
@@ -59,8 +542,10 @@ async function getAuthProfileRow(db, userId) {
59
542
  locale: userProfilesTable.locale,
60
543
  timezone: userProfilesTable.timezone,
61
544
  seatingPreference: userProfilesTable.seatingPreference,
545
+ documentsEncrypted: userProfilesTable.documentsEncrypted,
62
546
  marketingConsent: userProfilesTable.marketingConsent,
63
547
  marketingConsentAt: userProfilesTable.marketingConsentAt,
548
+ marketingConsentSource: userProfilesTable.marketingConsentSource,
64
549
  notificationDefaults: userProfilesTable.notificationDefaults,
65
550
  uiPrefs: userProfilesTable.uiPrefs,
66
551
  })
@@ -70,6 +555,21 @@ async function getAuthProfileRow(db, userId) {
70
555
  .limit(1);
71
556
  return row ?? null;
72
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
+ }
73
573
  async function resolveLinkedCustomerRecordId(db, userId) {
74
574
  const [row] = await db
75
575
  .select({ id: people.id })
@@ -114,6 +614,7 @@ async function listCustomerRecordCandidatesByEmail(db, email) {
114
614
  address: null,
115
615
  city: null,
116
616
  country: null,
617
+ billingAddress: null,
117
618
  relation: row.relation ?? null,
118
619
  status: row.status,
119
620
  claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
@@ -121,15 +622,62 @@ async function listCustomerRecordCandidatesByEmail(db, email) {
121
622
  }));
122
623
  return candidates;
123
624
  }
625
+ async function listCustomerRecordCandidatesByPhone(db, phone) {
626
+ const normalizedPhone = normalizePhone(phone);
627
+ const rows = await db
628
+ .select({
629
+ id: people.id,
630
+ firstName: people.firstName,
631
+ lastName: people.lastName,
632
+ preferredLanguage: people.preferredLanguage,
633
+ preferredCurrency: people.preferredCurrency,
634
+ birthday: people.birthday,
635
+ relation: people.relation,
636
+ status: people.status,
637
+ source: people.source,
638
+ sourceRef: people.sourceRef,
639
+ })
640
+ .from(people)
641
+ .innerJoin(identityContactPoints, and(eq(identityContactPoints.entityType, "person"), eq(identityContactPoints.entityId, people.id), inArray(identityContactPoints.kind, ["phone", "mobile", "whatsapp", "sms"]), or(eq(identityContactPoints.normalizedValue, normalizedPhone), eq(identityContactPoints.value, normalizedPhone))))
642
+ .orderBy(desc(people.updatedAt));
643
+ const uniqueRows = new Map();
644
+ for (const row of rows) {
645
+ if (!uniqueRows.has(row.id)) {
646
+ uniqueRows.set(row.id, row);
647
+ }
648
+ }
649
+ return Array.from(uniqueRows.values()).map((row) => ({
650
+ id: row.id,
651
+ firstName: row.firstName,
652
+ lastName: row.lastName,
653
+ preferredLanguage: row.preferredLanguage ?? null,
654
+ preferredCurrency: row.preferredCurrency ?? null,
655
+ birthday: row.birthday ?? null,
656
+ email: null,
657
+ phone: normalizedPhone,
658
+ address: null,
659
+ city: null,
660
+ country: null,
661
+ billingAddress: null,
662
+ relation: row.relation ?? null,
663
+ status: row.status,
664
+ claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
665
+ linkable: row.source === linkedCustomerSource ? row.sourceRef == null : row.sourceRef == null,
666
+ }));
667
+ }
124
668
  async function getCustomerRecord(db, userId) {
125
669
  const personId = await resolveLinkedCustomerRecordId(db, userId);
126
670
  if (!personId) {
127
671
  return null;
128
672
  }
129
- 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
+ ]);
130
677
  if (!person) {
131
678
  return null;
132
679
  }
680
+ const billingAddress = selectPreferredAddress(addresses);
133
681
  return {
134
682
  id: person.id,
135
683
  firstName: person.firstName,
@@ -142,10 +690,40 @@ async function getCustomerRecord(db, userId) {
142
690
  address: person.address ?? null,
143
691
  city: person.city ?? null,
144
692
  country: person.country ?? null,
693
+ billingAddress: billingAddress ? toCustomerAddress(billingAddress) : null,
145
694
  relation: person.relation ?? null,
146
695
  status: person.status,
147
696
  };
148
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
+ }
149
727
  async function getAccessibleBookingIds(db, params) {
150
728
  const linkedPersonId = await resolveLinkedCustomerRecordId(db, params.userId);
151
729
  const email = params.email.trim().toLowerCase();
@@ -169,12 +747,76 @@ async function getAccessibleBookingIds(db, params) {
169
747
  ]);
170
748
  return Array.from(new Set([...directBookingRows, ...participantPersonRows, ...participantEmailRows].map((row) => row.bookingId)));
171
749
  }
172
- 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) {
173
815
  const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
174
816
  if (!booking) {
175
817
  return null;
176
818
  }
177
- const [participants, items, itemParticipantLinks, documents, fulfillments] = await Promise.all([
819
+ const [participants, items, itemParticipantLinks, documents, fulfillments, legalDocuments, financeData, billingContact,] = await Promise.all([
178
820
  db
179
821
  .select()
180
822
  .from(bookingParticipants)
@@ -207,6 +849,9 @@ async function buildBookingDetail(db, bookingId) {
207
849
  .from(bookingFulfillments)
208
850
  .where(eq(bookingFulfillments.bookingId, booking.id))
209
851
  .orderBy(asc(bookingFulfillments.createdAt)),
852
+ listLegalDocumentsForBooking(db, booking.id),
853
+ getFinanceDataForBooking(db, booking.id),
854
+ getBookingBillingContact(db, booking.id, customerRecord),
210
855
  ]);
211
856
  const itemLinksByItemId = new Map();
212
857
  for (const link of itemParticipantLinks) {
@@ -219,6 +864,24 @@ async function buildBookingDetail(db, bookingId) {
219
864
  });
220
865
  itemLinksByItemId.set(link.bookingItemId, existing);
221
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
+ };
222
885
  return customerPortalBookingDetailSchema.parse({
223
886
  bookingId: booking.id,
224
887
  bookingNumber: booking.bookingNumber,
@@ -254,13 +917,9 @@ async function buildBookingDetail(db, bookingId) {
254
917
  notes: item.notes ?? null,
255
918
  participantLinks: itemLinksByItemId.get(item.id) ?? [],
256
919
  })),
257
- documents: documents.map((document) => ({
258
- id: document.id,
259
- participantId: document.participantId ?? null,
260
- type: document.type,
261
- fileName: document.fileName,
262
- fileUrl: document.fileUrl,
263
- })),
920
+ billingContact,
921
+ documents: unifiedDocuments,
922
+ financials,
264
923
  fulfillments: fulfillments.map((fulfillment) => ({
265
924
  id: fulfillment.id,
266
925
  bookingItemId: fulfillment.bookingItemId ?? null,
@@ -290,7 +949,19 @@ export const publicCustomerPortalService = {
290
949
  linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
291
950
  };
292
951
  },
952
+ async phoneContactExists(db, phone) {
953
+ const normalizedPhone = normalizePhone(phone);
954
+ const customerCandidates = await listCustomerRecordCandidatesByPhone(db, normalizedPhone);
955
+ return {
956
+ phone: normalizedPhone,
957
+ customerRecordExists: customerCandidates.length > 0,
958
+ linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
959
+ };
960
+ },
293
961
  async getProfile(db, userId) {
962
+ return this.getProfileWithOptions(db, userId);
963
+ },
964
+ async getProfileWithOptions(db, userId, options) {
294
965
  const [authProfile, customerRecord] = await Promise.all([
295
966
  getAuthProfileRow(db, userId),
296
967
  getCustomerRecord(db, userId),
@@ -298,24 +969,43 @@ export const publicCustomerPortalService = {
298
969
  if (!authProfile) {
299
970
  return null;
300
971
  }
972
+ const documents = await getProfileDocuments(authProfile, options);
973
+ const billingAddress = customerRecord?.billingAddress ?? null;
301
974
  return {
302
975
  userId: authProfile.id,
303
976
  email: authProfile.email,
304
977
  emailVerified: authProfile.emailVerified,
305
978
  firstName: authProfile.firstName ?? null,
979
+ middleName: deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName),
306
980
  lastName: authProfile.lastName ?? null,
307
981
  avatarUrl: authProfile.avatarUrl ?? authProfile.image ?? null,
308
982
  locale: authProfile.locale ?? "en",
309
983
  timezone: authProfile.timezone ?? null,
310
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,
311
997
  marketingConsent: authProfile.marketingConsent ?? false,
312
998
  marketingConsentAt: normalizeDateTime(authProfile.marketingConsentAt),
999
+ marketingConsentSource: authProfile.marketingConsentSource ?? null,
313
1000
  notificationDefaults: authProfile.notificationDefaults ?? null,
314
1001
  uiPrefs: authProfile.uiPrefs ?? null,
315
1002
  customerRecord,
316
1003
  };
317
1004
  },
318
1005
  async updateProfile(db, userId, input) {
1006
+ return this.updateProfileWithOptions(db, userId, input);
1007
+ },
1008
+ async updateProfileWithOptions(db, userId, input, options) {
319
1009
  const authProfile = await getAuthProfileRow(db, userId);
320
1010
  if (!authProfile) {
321
1011
  return { error: "not_found" };
@@ -324,31 +1014,63 @@ export const publicCustomerPortalService = {
324
1014
  if (input.customerRecord && !customerRecordId) {
325
1015
  return { error: "customer_record_required" };
326
1016
  }
1017
+ const existingMiddleName = deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName);
327
1018
  const nextFirstName = input.firstName ?? authProfile.firstName ?? null;
1019
+ const nextMiddleName = input.middleName ?? existingMiddleName;
328
1020
  const nextLastName = input.lastName ?? authProfile.lastName ?? null;
329
- 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;
330
1058
  await db
331
1059
  .insert(userProfilesTable)
332
1060
  .values({
333
1061
  id: userId,
334
1062
  firstName: nextFirstName,
335
1063
  lastName: nextLastName,
1064
+ ...(documentsEncrypted !== undefined ? { documentsEncrypted } : {}),
336
1065
  avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
337
1066
  locale: input.locale ?? authProfile.locale ?? "en",
338
1067
  timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
339
1068
  seatingPreference: input.seatingPreference !== undefined
340
1069
  ? input.seatingPreference
341
1070
  : (authProfile.seatingPreference ?? null),
342
- marketingConsent: input.marketingConsent !== undefined
343
- ? input.marketingConsent
344
- : (authProfile.marketingConsent ?? false),
345
- marketingConsentAt: input.marketingConsent === undefined
346
- ? (authProfile.marketingConsentAt ?? null)
347
- : input.marketingConsent
348
- ? authProfile.marketingConsent
349
- ? (authProfile.marketingConsentAt ?? new Date())
350
- : new Date()
351
- : null,
1071
+ marketingConsent: nextMarketingConsent.marketingConsent,
1072
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1073
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
352
1074
  notificationDefaults: input.notificationDefaults !== undefined
353
1075
  ? input.notificationDefaults
354
1076
  : (authProfile.notificationDefaults ?? {}),
@@ -361,22 +1083,16 @@ export const publicCustomerPortalService = {
361
1083
  set: {
362
1084
  firstName: nextFirstName,
363
1085
  lastName: nextLastName,
1086
+ ...(documentsEncrypted !== undefined ? { documentsEncrypted } : {}),
364
1087
  avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
365
1088
  locale: input.locale ?? authProfile.locale ?? "en",
366
1089
  timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
367
1090
  seatingPreference: input.seatingPreference !== undefined
368
1091
  ? input.seatingPreference
369
1092
  : (authProfile.seatingPreference ?? null),
370
- marketingConsent: input.marketingConsent !== undefined
371
- ? input.marketingConsent
372
- : (authProfile.marketingConsent ?? false),
373
- marketingConsentAt: input.marketingConsent === undefined
374
- ? (authProfile.marketingConsentAt ?? null)
375
- : input.marketingConsent
376
- ? authProfile.marketingConsent
377
- ? (authProfile.marketingConsentAt ?? new Date())
378
- : new Date()
379
- : null,
1093
+ marketingConsent: nextMarketingConsent.marketingConsent,
1094
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1095
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
380
1096
  notificationDefaults: input.notificationDefaults !== undefined
381
1097
  ? input.notificationDefaults
382
1098
  : (authProfile.notificationDefaults ?? {}),
@@ -395,8 +1111,23 @@ export const publicCustomerPortalService = {
395
1111
  })
396
1112
  .where(eq(authUser.id, userId));
397
1113
  if (customerRecordId) {
398
- 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;
399
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;
400
1131
  await crmService.updatePerson(db, customerRecordId, {
401
1132
  ...(input.firstName !== undefined ? { firstName: input.firstName ?? "" } : {}),
402
1133
  ...(input.lastName !== undefined ? { lastName: input.lastName ?? "" } : {}),
@@ -417,10 +1148,27 @@ export const publicCustomerPortalService = {
417
1148
  ...(nextCustomerRecord?.country !== undefined
418
1149
  ? { country: nextCustomerRecord.country }
419
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
+ : {}),
420
1168
  });
421
1169
  }
422
1170
  }
423
- const profile = await this.getProfile(db, userId);
1171
+ const profile = await this.getProfileWithOptions(db, userId, options);
424
1172
  if (!profile) {
425
1173
  return { error: "not_found" };
426
1174
  }
@@ -443,19 +1191,28 @@ export const publicCustomerPortalService = {
443
1191
  const normalizedEmail = normalizeEmail(authProfile.email);
444
1192
  const nextFirstName = input.firstName ?? authProfile.firstName ?? authProfile.name.split(" ")[0] ?? "Customer";
445
1193
  const nextLastName = input.lastName ?? authProfile.lastName ?? authProfile.name.split(" ").slice(1).join(" ") ?? "";
446
- 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
+ });
447
1202
  await db
448
1203
  .insert(userProfilesTable)
449
1204
  .values({
450
1205
  id: userId,
451
- marketingConsent: input.marketingConsent,
452
- marketingConsentAt: input.marketingConsent ? new Date() : null,
1206
+ marketingConsent: nextMarketingConsent.marketingConsent,
1207
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1208
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
453
1209
  })
454
1210
  .onConflictDoUpdate({
455
1211
  target: userProfilesTable.id,
456
1212
  set: {
457
- marketingConsent: input.marketingConsent,
458
- marketingConsentAt: input.marketingConsent ? new Date() : null,
1213
+ marketingConsent: nextMarketingConsent.marketingConsent,
1214
+ marketingConsentAt: nextMarketingConsent.marketingConsentAt,
1215
+ marketingConsentSource: nextMarketingConsent.marketingConsentSource,
459
1216
  updatedAt: new Date(),
460
1217
  },
461
1218
  });
@@ -492,10 +1249,30 @@ export const publicCustomerPortalService = {
492
1249
  ...(input.customerRecord?.country !== undefined
493
1250
  ? { country: input.customerRecord.country }
494
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
+ : {}),
495
1265
  });
496
1266
  if (!updated) {
497
1267
  return { error: "customer_record_not_found" };
498
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
+ }
499
1276
  const profile = await this.getProfile(db, userId);
500
1277
  return {
501
1278
  status: "linked_existing_customer",
@@ -533,13 +1310,32 @@ export const publicCustomerPortalService = {
533
1310
  email: normalizedEmail,
534
1311
  phone: input.customerRecord?.phone ?? null,
535
1312
  website: null,
536
- address: input.customerRecord?.address ?? null,
537
- city: input.customerRecord?.city ?? null,
538
- 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),
539
1328
  });
540
1329
  if (!created) {
541
1330
  return { error: "not_found" };
542
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
+ }
543
1339
  const profile = await this.getProfile(db, userId);
544
1340
  return {
545
1341
  status: "created_customer",
@@ -558,6 +1354,91 @@ export const publicCustomerPortalService = {
558
1354
  companionMetadataKind)
559
1355
  .map(toCustomerCompanion);
560
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
+ },
561
1442
  async createCompanion(db, userId, input) {
562
1443
  const personId = await resolveLinkedCustomerRecordId(db, userId);
563
1444
  if (!personId) {
@@ -573,10 +1454,11 @@ export const publicCustomerPortalService = {
573
1454
  phone: normalizeNullableString(input.phone),
574
1455
  isPrimary: input.isPrimary,
575
1456
  notes: normalizeNullableString(input.notes),
576
- metadata: {
577
- kind: companionMetadataKind,
578
- ...(input.metadata ?? {}),
579
- },
1457
+ metadata: buildStoredCompanionMetadata({
1458
+ metadata: input.metadata ?? undefined,
1459
+ typeKey: input.typeKey,
1460
+ person: input.person,
1461
+ }),
580
1462
  });
581
1463
  return row ? toCustomerCompanion(row) : null;
582
1464
  },
@@ -601,12 +1483,16 @@ export const publicCustomerPortalService = {
601
1483
  ...(input.phone !== undefined ? { phone: normalizeNullableString(input.phone) } : {}),
602
1484
  ...(input.isPrimary !== undefined ? { isPrimary: input.isPrimary } : {}),
603
1485
  ...(input.notes !== undefined ? { notes: normalizeNullableString(input.notes) } : {}),
604
- ...(input.metadata !== undefined
1486
+ ...(input.metadata !== undefined || input.typeKey !== undefined || input.person !== undefined
605
1487
  ? {
606
- metadata: {
607
- kind: companionMetadataKind,
608
- ...(input.metadata ?? {}),
609
- },
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
+ }),
610
1496
  }
611
1497
  : {}),
612
1498
  });
@@ -639,7 +1525,7 @@ export const publicCustomerPortalService = {
639
1525
  if (bookingIds.length === 0) {
640
1526
  return [];
641
1527
  }
642
- const [bookingRows, participantRows] = await Promise.all([
1528
+ const [bookingRows, participantRows, itemRows, invoiceRows] = await Promise.all([
643
1529
  db
644
1530
  .select()
645
1531
  .from(bookings)
@@ -650,6 +1536,28 @@ export const publicCustomerPortalService = {
650
1536
  .from(bookingParticipants)
651
1537
  .where(inArray(bookingParticipants.bookingId, bookingIds))
652
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)),
653
1561
  ]);
654
1562
  const participantsByBookingId = new Map();
655
1563
  for (const participant of participantRows) {
@@ -657,8 +1565,22 @@ export const publicCustomerPortalService = {
657
1565
  bucket.push(participant);
658
1566
  participantsByBookingId.set(participant.bookingId, bucket);
659
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
+ }
660
1580
  return bookingRows.map((booking) => {
661
1581
  const participants = participantsByBookingId.get(booking.id) ?? [];
1582
+ const items = itemsByBookingId.get(booking.id) ?? [];
1583
+ const bookingInvoices = invoicesByBookingId.get(booking.id) ?? [];
662
1584
  const primaryTraveler = participants.find((participant) => participant.isPrimary) ?? participants[0] ?? null;
663
1585
  return {
664
1586
  bookingId: booking.id,
@@ -666,6 +1588,8 @@ export const publicCustomerPortalService = {
666
1588
  status: booking.status,
667
1589
  sellCurrency: booking.sellCurrency,
668
1590
  sellAmountCents: booking.sellAmountCents ?? null,
1591
+ productTitle: selectBookingSummaryProductTitle(items),
1592
+ paymentStatus: deriveBookingSummaryPaymentStatus(bookingInvoices, booking.sellAmountCents ?? null),
669
1593
  startDate: normalizeDate(booking.startDate),
670
1594
  endDate: normalizeDate(booking.endDate),
671
1595
  pax: booking.pax ?? null,
@@ -683,33 +1607,46 @@ export const publicCustomerPortalService = {
683
1607
  if (!authProfile) {
684
1608
  return null;
685
1609
  }
686
- const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
687
- const authEmail = authProfile.email.trim().toLowerCase();
688
- const ownershipConditions = [sql `lower(${bookingParticipants.email}) = ${authEmail}`];
689
- if (linkedPersonId) {
690
- ownershipConditions.push(eq(bookingParticipants.personId, linkedPersonId));
691
- }
692
- const [participantMatch, bookingMatch] = await Promise.all([
693
- db
694
- .select({ bookingId: bookingParticipants.bookingId })
695
- .from(bookingParticipants)
696
- .where(and(eq(bookingParticipants.bookingId, bookingId), or(...ownershipConditions)))
697
- .limit(1),
698
- linkedPersonId
699
- ? db
700
- .select({ bookingId: bookings.id })
701
- .from(bookings)
702
- .where(and(eq(bookings.id, bookingId), eq(bookings.personId, linkedPersonId)))
703
- .limit(1)
704
- : Promise.resolve([]),
1610
+ const [linkedPersonId, customerRecord] = await Promise.all([
1611
+ resolveLinkedCustomerRecordId(db, userId),
1612
+ getCustomerRecord(db, userId),
705
1613
  ]);
706
- 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) {
707
1623
  return null;
708
1624
  }
709
- return buildBookingDetail(db, bookingId);
1625
+ return buildBookingDetail(db, bookingId, customerRecord);
710
1626
  },
711
1627
  async listBookingDocuments(db, userId, bookingId) {
712
1628
  const detail = await this.getBooking(db, userId, bookingId);
713
1629
  return detail?.documents ?? null;
714
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
+ },
715
1652
  };