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