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