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