@voyant-travel/storefront 0.120.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/LICENSE +201 -0
- package/README.md +231 -0
- package/dist/booking-intents.d.ts +42 -0
- package/dist/booking-intents.d.ts.map +1 -0
- package/dist/booking-intents.js +83 -0
- package/dist/customer-portal/index.d.ts +16 -0
- package/dist/customer-portal/index.d.ts.map +1 -0
- package/dist/customer-portal/index.js +23 -0
- package/dist/customer-portal/route-runtime.d.ts +16 -0
- package/dist/customer-portal/route-runtime.d.ts.map +1 -0
- package/dist/customer-portal/route-runtime.js +27 -0
- package/dist/customer-portal/routes-public.d.ts +1936 -0
- package/dist/customer-portal/routes-public.d.ts.map +1 -0
- package/dist/customer-portal/routes-public.js +165 -0
- package/dist/customer-portal/routes.d.ts +43 -0
- package/dist/customer-portal/routes.d.ts.map +1 -0
- package/dist/customer-portal/routes.js +17 -0
- package/dist/customer-portal/service-public-impl.d.ts +138 -0
- package/dist/customer-portal/service-public-impl.d.ts.map +1 -0
- package/dist/customer-portal/service-public-impl.js +1808 -0
- package/dist/customer-portal/service-public.d.ts +2 -0
- package/dist/customer-portal/service-public.d.ts.map +1 -0
- package/dist/customer-portal/service-public.js +1 -0
- package/dist/customer-portal/validation-public/bookings.d.ts +551 -0
- package/dist/customer-portal/validation-public/bookings.d.ts.map +1 -0
- package/dist/customer-portal/validation-public/bookings.js +132 -0
- package/dist/customer-portal/validation-public/common.d.ts +162 -0
- package/dist/customer-portal/validation-public/common.d.ts.map +1 -0
- package/dist/customer-portal/validation-public/common.js +139 -0
- package/dist/customer-portal/validation-public/profile.d.ts +749 -0
- package/dist/customer-portal/validation-public/profile.d.ts.map +1 -0
- package/dist/customer-portal/validation-public/profile.js +308 -0
- package/dist/customer-portal/validation-public.d.ts +3 -0
- package/dist/customer-portal/validation-public.d.ts.map +1 -0
- package/dist/customer-portal/validation-public.js +2 -0
- package/dist/guest-booking-guard.d.ts +24 -0
- package/dist/guest-booking-guard.d.ts.map +1 -0
- package/dist/guest-booking-guard.js +55 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/product-extra-ref.d.ts +238 -0
- package/dist/product-extra-ref.d.ts.map +1 -0
- package/dist/product-extra-ref.js +22 -0
- package/dist/routes-admin.d.ts +220 -0
- package/dist/routes-admin.d.ts.map +1 -0
- package/dist/routes-admin.js +28 -0
- package/dist/routes-public.d.ts +1475 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +362 -0
- package/dist/service-booking-session-bootstrap.d.ts +227 -0
- package/dist/service-booking-session-bootstrap.d.ts.map +1 -0
- package/dist/service-booking-session-bootstrap.js +287 -0
- package/dist/service-boundary-resource-sql.d.ts +18 -0
- package/dist/service-boundary-resource-sql.d.ts.map +1 -0
- package/dist/service-boundary-resource-sql.js +73 -0
- package/dist/service-boundary-sql.d.ts +103 -0
- package/dist/service-boundary-sql.d.ts.map +1 -0
- package/dist/service-boundary-sql.js +307 -0
- package/dist/service-departures-core.d.ts +41 -0
- package/dist/service-departures-core.d.ts.map +1 -0
- package/dist/service-departures-core.js +92 -0
- package/dist/service-departures-extensions.d.ts +46 -0
- package/dist/service-departures-extensions.d.ts.map +1 -0
- package/dist/service-departures-extensions.js +81 -0
- package/dist/service-departures-offers.d.ts +220 -0
- package/dist/service-departures-offers.d.ts.map +1 -0
- package/dist/service-departures-offers.js +177 -0
- package/dist/service-departures-price-preview.d.ts +306 -0
- package/dist/service-departures-price-preview.d.ts.map +1 -0
- package/dist/service-departures-price-preview.js +383 -0
- package/dist/service-departures-pricing-context.d.ts +115 -0
- package/dist/service-departures-pricing-context.d.ts.map +1 -0
- package/dist/service-departures-pricing-context.js +237 -0
- package/dist/service-departures-pricing.d.ts +5 -0
- package/dist/service-departures-pricing.d.ts.map +1 -0
- package/dist/service-departures-pricing.js +4 -0
- package/dist/service-departures.d.ts +192 -0
- package/dist/service-departures.d.ts.map +1 -0
- package/dist/service-departures.js +213 -0
- package/dist/service-intake.d.ts +130 -0
- package/dist/service-intake.d.ts.map +1 -0
- package/dist/service-intake.js +274 -0
- package/dist/service-transport-eligibility.d.ts +10 -0
- package/dist/service-transport-eligibility.d.ts.map +1 -0
- package/dist/service-transport-eligibility.js +198 -0
- package/dist/service.d.ts +1062 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +332 -0
- package/dist/transport-eligibility.d.ts +4 -0
- package/dist/transport-eligibility.d.ts.map +1 -0
- package/dist/transport-eligibility.js +2 -0
- package/dist/validation/departures.d.ts +1669 -0
- package/dist/validation/departures.d.ts.map +1 -0
- package/dist/validation/departures.js +397 -0
- package/dist/validation/intake.d.ts +147 -0
- package/dist/validation/intake.d.ts.map +1 -0
- package/dist/validation/intake.js +69 -0
- package/dist/validation/offers.d.ts +340 -0
- package/dist/validation/offers.d.ts.map +1 -0
- package/dist/validation/offers.js +117 -0
- package/dist/validation-settings.d.ts +609 -0
- package/dist/validation-settings.d.ts.map +1 -0
- package/dist/validation-settings.js +235 -0
- package/dist/validation-transport-eligibility.d.ts +314 -0
- package/dist/validation-transport-eligibility.d.ts.map +1 -0
- package/dist/validation-transport-eligibility.js +97 -0
- package/dist/validation.d.ts +6 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +4 -0
- package/dist/verification/index.d.ts +12 -0
- package/dist/verification/index.d.ts.map +1 -0
- package/dist/verification/index.js +18 -0
- package/dist/verification/routes-public.d.ts +121 -0
- package/dist/verification/routes-public.d.ts.map +1 -0
- package/dist/verification/routes-public.js +125 -0
- package/dist/verification/schema.d.ts +273 -0
- package/dist/verification/schema.d.ts.map +1 -0
- package/dist/verification/schema.js +50 -0
- package/dist/verification/service.d.ts +114 -0
- package/dist/verification/service.d.ts.map +1 -0
- package/dist/verification/service.js +283 -0
- package/dist/verification/validation.d.ts +98 -0
- package/dist/verification/validation.d.ts.map +1 -0
- package/dist/verification/validation.js +54 -0
- package/package.json +148 -0
|
@@ -0,0 +1,1808 @@
|
|
|
1
|
+
// agent-quality: file-size exception -- owner: customer-portal; existing service module stays co-located until a dedicated split preserves behavior and tests.
|
|
2
|
+
import { bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingSessionStates, bookingStaffAssignments, bookings, bookingTravelers, } from "@voyant-travel/bookings/schema";
|
|
3
|
+
import { authUser, userProfilesTable } from "@voyant-travel/db/schema/iam";
|
|
4
|
+
import { invoiceRenditions, invoices, payments } from "@voyant-travel/finance/schema";
|
|
5
|
+
import { identityContactPoints } from "@voyant-travel/identity/schema";
|
|
6
|
+
import { identityService } from "@voyant-travel/identity/service";
|
|
7
|
+
import { contractAttachments, contracts } from "@voyant-travel/legal/schema";
|
|
8
|
+
import { people, personDocumentNumberPlaintextSchema, personPiiBlobPlaintextSchema, relationshipsService, } from "@voyant-travel/relationships";
|
|
9
|
+
import { decryptOptionalJsonEnvelope, encryptOptionalJsonEnvelope, } from "@voyant-travel/utils";
|
|
10
|
+
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
|
|
11
|
+
import { customerPortalBookingDetailSchema } from "./validation-public.js";
|
|
12
|
+
const linkedCustomerSource = "auth.user";
|
|
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
|
+
}
|
|
39
|
+
function normalizeDate(value) {
|
|
40
|
+
if (!value) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (value instanceof Date) {
|
|
44
|
+
return value.toISOString().slice(0, 10);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function normalizeDateTime(value) {
|
|
49
|
+
if (!value) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
53
|
+
}
|
|
54
|
+
function normalizeNullableString(value) {
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const trimmed = value?.trim();
|
|
59
|
+
return trimmed ? trimmed : null;
|
|
60
|
+
}
|
|
61
|
+
function normalizeEmail(value) {
|
|
62
|
+
return value.trim().toLowerCase();
|
|
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 toCrmDocumentType(type) {
|
|
87
|
+
return type === "drivers_license" ? "driver_license" : type;
|
|
88
|
+
}
|
|
89
|
+
function toWireDocumentType(type) {
|
|
90
|
+
return type === "driver_license" ? "drivers_license" : 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 toCustomerAddress(address) {
|
|
114
|
+
return {
|
|
115
|
+
id: address.id,
|
|
116
|
+
label: address.label,
|
|
117
|
+
fullText: address.fullText ?? null,
|
|
118
|
+
line1: address.line1 ?? null,
|
|
119
|
+
line2: address.line2 ?? null,
|
|
120
|
+
city: address.city ?? null,
|
|
121
|
+
region: address.region ?? null,
|
|
122
|
+
postalCode: address.postalCode ?? null,
|
|
123
|
+
country: address.country ?? null,
|
|
124
|
+
isPrimary: address.isPrimary,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function getNestedRecord(record, keys) {
|
|
128
|
+
for (const key of keys) {
|
|
129
|
+
const value = getRecord(record?.[key]);
|
|
130
|
+
if (value) {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function getRecordString(record, keys) {
|
|
137
|
+
for (const key of keys) {
|
|
138
|
+
const value = record?.[key];
|
|
139
|
+
if (typeof value === "string") {
|
|
140
|
+
const trimmed = value.trim();
|
|
141
|
+
if (trimmed.length > 0) {
|
|
142
|
+
return trimmed;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function getRecordBoolean(record, keys) {
|
|
149
|
+
for (const key of keys) {
|
|
150
|
+
const value = record?.[key];
|
|
151
|
+
if (typeof value === "boolean") {
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function splitCompanionName(value) {
|
|
158
|
+
const parts = String(value ?? "")
|
|
159
|
+
.trim()
|
|
160
|
+
.split(/\s+/)
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
if (parts.length === 0) {
|
|
163
|
+
return {
|
|
164
|
+
firstName: null,
|
|
165
|
+
middleName: null,
|
|
166
|
+
lastName: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (parts.length === 1) {
|
|
170
|
+
return {
|
|
171
|
+
firstName: parts[0] ?? null,
|
|
172
|
+
middleName: null,
|
|
173
|
+
lastName: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
firstName: parts[0] ?? null,
|
|
178
|
+
middleName: parts.length > 2 ? parts.slice(1, -1).join(" ") : null,
|
|
179
|
+
lastName: parts.at(-1) ?? null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function normalizeCompanionAddressRecord(value) {
|
|
183
|
+
return {
|
|
184
|
+
type: getRecordString(value, ["type"]) ?? null,
|
|
185
|
+
country: getRecordString(value, ["country"]) ?? null,
|
|
186
|
+
state: getRecordString(value, ["state", "region"]) ?? null,
|
|
187
|
+
city: getRecordString(value, ["city"]) ?? null,
|
|
188
|
+
postalCode: getRecordString(value, ["postalCode", "postal"]) ?? null,
|
|
189
|
+
addressLine1: getRecordString(value, ["addressLine1", "line1"]) ?? null,
|
|
190
|
+
addressLine2: getRecordString(value, ["addressLine2", "line2"]) ?? null,
|
|
191
|
+
isDefault: getRecordBoolean(value, ["isDefault"]) ?? false,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function normalizeCompanionDocumentRecord(value) {
|
|
195
|
+
const type = getRecordString(value, ["type"]);
|
|
196
|
+
if (type !== "passport" &&
|
|
197
|
+
type !== "id_card" &&
|
|
198
|
+
type !== "visa" &&
|
|
199
|
+
type !== "drivers_license" &&
|
|
200
|
+
type !== "other") {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
type,
|
|
205
|
+
number: getRecordString(value, ["number"]) ?? null,
|
|
206
|
+
issuingAuthority: getRecordString(value, ["issuingAuthority"]) ?? null,
|
|
207
|
+
country: getRecordString(value, ["country", "issuingCountry"]) ?? null,
|
|
208
|
+
issueDate: getRecordString(value, ["issueDate"]) ?? null,
|
|
209
|
+
expiryDate: getRecordString(value, ["expiryDate"]) ?? null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function getCompanionPersonMetadata(metadata) {
|
|
213
|
+
const personMetadata = getNestedRecord(metadata, ["person", "traveler", "identity"]);
|
|
214
|
+
const derivedName = splitCompanionName(getRecordString(metadata, ["name"]));
|
|
215
|
+
const addresses = Array.isArray(personMetadata?.addresses)
|
|
216
|
+
? personMetadata.addresses
|
|
217
|
+
.map((value) => normalizeCompanionAddressRecord(getRecord(value)))
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
: [];
|
|
220
|
+
const documents = Array.isArray(personMetadata?.documents)
|
|
221
|
+
? personMetadata.documents
|
|
222
|
+
.map((value) => normalizeCompanionDocumentRecord(getRecord(value)))
|
|
223
|
+
.filter((value) => Boolean(value))
|
|
224
|
+
: [];
|
|
225
|
+
return {
|
|
226
|
+
firstName: getRecordString(personMetadata, ["firstName"]) ?? derivedName.firstName,
|
|
227
|
+
middleName: getRecordString(personMetadata, ["middleName"]) ?? derivedName.middleName,
|
|
228
|
+
lastName: getRecordString(personMetadata, ["lastName"]) ?? derivedName.lastName,
|
|
229
|
+
dateOfBirth: getRecordString(personMetadata, ["dateOfBirth"]) ?? null,
|
|
230
|
+
addresses,
|
|
231
|
+
documents,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function getCompanionTypeKey(metadata) {
|
|
235
|
+
return getRecordString(metadata, ["typeKey", "relationshipType"]);
|
|
236
|
+
}
|
|
237
|
+
function buildStoredCompanionMetadata(input) {
|
|
238
|
+
const baseMetadata = input.metadata !== undefined
|
|
239
|
+
? { ...(input.metadata ?? {}) }
|
|
240
|
+
: { ...(input.existingMetadata ?? {}) };
|
|
241
|
+
baseMetadata.kind = companionMetadataKind;
|
|
242
|
+
if (input.typeKey !== undefined) {
|
|
243
|
+
const typeKey = normalizeNullableString(input.typeKey);
|
|
244
|
+
if (typeKey) {
|
|
245
|
+
baseMetadata.typeKey = typeKey;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
delete baseMetadata.typeKey;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (input.person !== undefined) {
|
|
252
|
+
baseMetadata.person = {
|
|
253
|
+
firstName: normalizeNullableString(input.person.firstName) ?? null,
|
|
254
|
+
middleName: normalizeNullableString(input.person.middleName) ?? null,
|
|
255
|
+
lastName: normalizeNullableString(input.person.lastName) ?? null,
|
|
256
|
+
dateOfBirth: normalizeNullableString(input.person.dateOfBirth) ?? null,
|
|
257
|
+
addresses: input.person.addresses?.map((address) => ({
|
|
258
|
+
type: normalizeNullableString(address.type) ?? null,
|
|
259
|
+
country: normalizeNullableString(address.country) ?? null,
|
|
260
|
+
state: normalizeNullableString(address.state) ?? null,
|
|
261
|
+
city: normalizeNullableString(address.city) ?? null,
|
|
262
|
+
postalCode: normalizeNullableString(address.postalCode) ?? null,
|
|
263
|
+
addressLine1: normalizeNullableString(address.addressLine1) ?? null,
|
|
264
|
+
addressLine2: normalizeNullableString(address.addressLine2) ?? null,
|
|
265
|
+
isDefault: address.isDefault ?? false,
|
|
266
|
+
})) ?? [],
|
|
267
|
+
documents: input.person.documents?.map((document) => ({
|
|
268
|
+
type: document.type,
|
|
269
|
+
number: normalizeNullableString(document.number) ?? null,
|
|
270
|
+
issuingAuthority: normalizeNullableString(document.issuingAuthority) ?? null,
|
|
271
|
+
country: normalizeNullableString(document.country) ?? null,
|
|
272
|
+
issueDate: normalizeNullableString(document.issueDate) ?? null,
|
|
273
|
+
expiryDate: normalizeNullableString(document.expiryDate) ?? null,
|
|
274
|
+
})) ?? [],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return baseMetadata;
|
|
278
|
+
}
|
|
279
|
+
function selectPreferredAddress(addresses) {
|
|
280
|
+
return (addresses.find((address) => address.label === "billing") ??
|
|
281
|
+
addresses.find((address) => address.isPrimary) ??
|
|
282
|
+
addresses[0] ??
|
|
283
|
+
null);
|
|
284
|
+
}
|
|
285
|
+
function resolveBillingContactFromSessionPayload(payload) {
|
|
286
|
+
const root = getRecord(payload);
|
|
287
|
+
const stepData = getNestedRecord(root, ["stepData", "steps"]);
|
|
288
|
+
const billingRecord = getNestedRecord(root, ["billing", "billingContact", "contact"]) ??
|
|
289
|
+
getNestedRecord(stepData, ["billing", "billingContact", "contact"]);
|
|
290
|
+
const billing = getNestedRecord(billingRecord, ["billing", "contact"]) ?? billingRecord;
|
|
291
|
+
if (!billing) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
email: getRecordString(billing, ["email"]),
|
|
296
|
+
phone: getRecordString(billing, ["phone"]),
|
|
297
|
+
firstName: getRecordString(billing, ["firstName"]),
|
|
298
|
+
lastName: getRecordString(billing, ["lastName"]),
|
|
299
|
+
country: getRecordString(billing, ["country"]),
|
|
300
|
+
state: getRecordString(billing, ["state", "region"]),
|
|
301
|
+
city: getRecordString(billing, ["city"]),
|
|
302
|
+
address1: getRecordString(billing, ["addressLine1", "address1", "line1"]),
|
|
303
|
+
address2: getRecordString(billing, ["addressLine2", "address2", "line2"]),
|
|
304
|
+
postal: getRecordString(billing, ["postalCode", "postal", "zip"]),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function resolveFinanceDocumentFileName(invoiceNumber, invoiceType, format) {
|
|
308
|
+
const extension = format ?? "pdf";
|
|
309
|
+
return `${invoiceType}-${invoiceNumber}.${extension}`;
|
|
310
|
+
}
|
|
311
|
+
async function listLegalDocumentsForBooking(db, bookingId, options = {}) {
|
|
312
|
+
const contractRows = await db
|
|
313
|
+
.select({
|
|
314
|
+
id: contracts.id,
|
|
315
|
+
contractNumber: contracts.contractNumber,
|
|
316
|
+
})
|
|
317
|
+
.from(contracts)
|
|
318
|
+
.where(eq(contracts.bookingId, bookingId))
|
|
319
|
+
.orderBy(desc(contracts.createdAt));
|
|
320
|
+
if (contractRows.length === 0) {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
const attachmentRows = await db
|
|
324
|
+
.select()
|
|
325
|
+
.from(contractAttachments)
|
|
326
|
+
.where(and(eq(contractAttachments.kind, "document"), or(...contractRows.map((contract) => eq(contractAttachments.contractId, contract.id)))))
|
|
327
|
+
.orderBy(desc(contractAttachments.createdAt));
|
|
328
|
+
const bestAttachmentByContractId = new Map();
|
|
329
|
+
for (const attachment of attachmentRows) {
|
|
330
|
+
const metadata = getMetadataRecord(attachment.metadata);
|
|
331
|
+
const downloadUrl = attachment.storageKey && options.resolveDocumentDownloadUrl
|
|
332
|
+
? await options.resolveDocumentDownloadUrl(attachment.storageKey)
|
|
333
|
+
: getMetadataString(metadata, ["url"]);
|
|
334
|
+
if (!downloadUrl || bestAttachmentByContractId.has(attachment.contractId)) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
bestAttachmentByContractId.set(attachment.contractId, { attachment, downloadUrl });
|
|
338
|
+
}
|
|
339
|
+
return contractRows.flatMap((contract) => {
|
|
340
|
+
const document = bestAttachmentByContractId.get(contract.id);
|
|
341
|
+
if (!document) {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
const { attachment, downloadUrl } = document;
|
|
345
|
+
return [
|
|
346
|
+
{
|
|
347
|
+
id: attachment.id,
|
|
348
|
+
source: "legal",
|
|
349
|
+
travelerId: null,
|
|
350
|
+
type: "contract",
|
|
351
|
+
fileName: attachment.name,
|
|
352
|
+
fileUrl: downloadUrl,
|
|
353
|
+
mimeType: attachment.mimeType ?? null,
|
|
354
|
+
reference: contract.contractNumber ?? null,
|
|
355
|
+
},
|
|
356
|
+
];
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
function resolveFinanceDocumentDownloadUrl(metadata) {
|
|
360
|
+
return getMetadataString(metadata, ["url"]);
|
|
361
|
+
}
|
|
362
|
+
function selectBookingSummaryProductTitle(items) {
|
|
363
|
+
const preferredItem = items.find((item) => item.itemType === "unit") ??
|
|
364
|
+
items.find((item) => item.itemType === "accommodation") ??
|
|
365
|
+
items.find((item) => item.itemType === "transport") ??
|
|
366
|
+
items[0] ??
|
|
367
|
+
null;
|
|
368
|
+
return preferredItem?.title ?? null;
|
|
369
|
+
}
|
|
370
|
+
function deriveBookingSummaryPaymentStatus(invoicesForBooking, fallbackSellAmountCents) {
|
|
371
|
+
const activeInvoices = invoicesForBooking.filter((invoice) => invoice.invoiceType !== "credit_note" && invoice.status !== "void");
|
|
372
|
+
if (activeInvoices.length === 0) {
|
|
373
|
+
return fallbackSellAmountCents && fallbackSellAmountCents > 0 ? "unpaid" : "paid";
|
|
374
|
+
}
|
|
375
|
+
if (activeInvoices.some((invoice) => invoice.status === "overdue" && invoice.balanceDueCents > 0)) {
|
|
376
|
+
return "overdue";
|
|
377
|
+
}
|
|
378
|
+
const totalPaidCents = activeInvoices.reduce((sum, invoice) => sum + Math.max(0, invoice.paidCents), 0);
|
|
379
|
+
const totalBalanceDueCents = activeInvoices.reduce((sum, invoice) => sum + Math.max(0, invoice.balanceDueCents), 0);
|
|
380
|
+
if (totalBalanceDueCents <= 0) {
|
|
381
|
+
return "paid";
|
|
382
|
+
}
|
|
383
|
+
if (totalPaidCents > 0) {
|
|
384
|
+
return "partially_paid";
|
|
385
|
+
}
|
|
386
|
+
return "unpaid";
|
|
387
|
+
}
|
|
388
|
+
async function getFinanceDataForBooking(db, bookingId, options = {}) {
|
|
389
|
+
const invoiceRows = await db
|
|
390
|
+
.select()
|
|
391
|
+
.from(invoices)
|
|
392
|
+
.where(eq(invoices.bookingId, bookingId))
|
|
393
|
+
.orderBy(desc(invoices.createdAt));
|
|
394
|
+
if (invoiceRows.length === 0) {
|
|
395
|
+
return { documents: [], payments: [], portalDocuments: [] };
|
|
396
|
+
}
|
|
397
|
+
const invoiceIds = invoiceRows.map((invoice) => invoice.id);
|
|
398
|
+
const renditionRows = await db
|
|
399
|
+
.select()
|
|
400
|
+
.from(invoiceRenditions)
|
|
401
|
+
.where(inArray(invoiceRenditions.invoiceId, invoiceIds))
|
|
402
|
+
.orderBy(desc(invoiceRenditions.createdAt));
|
|
403
|
+
const paymentRows = await db
|
|
404
|
+
.select()
|
|
405
|
+
.from(payments)
|
|
406
|
+
.where(inArray(payments.invoiceId, invoiceIds))
|
|
407
|
+
.orderBy(desc(payments.paymentDate), desc(payments.createdAt));
|
|
408
|
+
const renditionByInvoiceId = new Map();
|
|
409
|
+
for (const rendition of renditionRows) {
|
|
410
|
+
const existing = renditionByInvoiceId.get(rendition.invoiceId) ?? [];
|
|
411
|
+
existing.push(rendition);
|
|
412
|
+
renditionByInvoiceId.set(rendition.invoiceId, existing);
|
|
413
|
+
}
|
|
414
|
+
const invoiceById = new Map(invoiceRows.map((invoice) => [invoice.id, invoice]));
|
|
415
|
+
const resolvedDocuments = await Promise.all(invoiceRows.map(async (invoice) => {
|
|
416
|
+
const renditions = renditionByInvoiceId.get(invoice.id) ?? [];
|
|
417
|
+
const selectedRendition = renditions.find((rendition) => rendition.status === "ready") ?? renditions[0] ?? null;
|
|
418
|
+
const metadata = getMetadataRecord(selectedRendition?.metadata ?? null);
|
|
419
|
+
const downloadUrl = selectedRendition?.storageKey && options.resolveDocumentDownloadUrl
|
|
420
|
+
? await options.resolveDocumentDownloadUrl(selectedRendition.storageKey)
|
|
421
|
+
: resolveFinanceDocumentDownloadUrl(metadata);
|
|
422
|
+
return {
|
|
423
|
+
invoiceId: invoice.id,
|
|
424
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
425
|
+
invoiceType: invoice.invoiceType,
|
|
426
|
+
invoiceStatus: invoice.status,
|
|
427
|
+
currency: invoice.currency,
|
|
428
|
+
totalCents: invoice.totalCents,
|
|
429
|
+
paidCents: invoice.paidCents,
|
|
430
|
+
balanceDueCents: invoice.balanceDueCents,
|
|
431
|
+
issueDate: invoice.issueDate,
|
|
432
|
+
dueDate: invoice.dueDate,
|
|
433
|
+
documentStatus: selectedRendition?.status ?? "missing",
|
|
434
|
+
format: selectedRendition?.format ?? null,
|
|
435
|
+
generatedAt: normalizeDateTime(selectedRendition?.generatedAt ?? null),
|
|
436
|
+
downloadUrl,
|
|
437
|
+
};
|
|
438
|
+
}));
|
|
439
|
+
const paymentHistory = paymentRows.flatMap((payment) => {
|
|
440
|
+
const invoice = invoiceById.get(payment.invoiceId);
|
|
441
|
+
if (!invoice) {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
id: payment.id,
|
|
447
|
+
invoiceId: invoice.id,
|
|
448
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
449
|
+
invoiceType: invoice.invoiceType,
|
|
450
|
+
status: payment.status,
|
|
451
|
+
paymentMethod: payment.paymentMethod,
|
|
452
|
+
amountCents: payment.amountCents,
|
|
453
|
+
currency: payment.currency,
|
|
454
|
+
paymentDate: payment.paymentDate,
|
|
455
|
+
referenceNumber: payment.referenceNumber ?? null,
|
|
456
|
+
notes: payment.notes ?? null,
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
});
|
|
460
|
+
const portalDocuments = resolvedDocuments.flatMap((document) => {
|
|
461
|
+
if (!document.downloadUrl) {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
return [
|
|
465
|
+
{
|
|
466
|
+
id: document.invoiceId,
|
|
467
|
+
source: "finance",
|
|
468
|
+
travelerId: null,
|
|
469
|
+
type: document.invoiceType,
|
|
470
|
+
fileName: resolveFinanceDocumentFileName(document.invoiceNumber, document.invoiceType, document.format),
|
|
471
|
+
fileUrl: document.downloadUrl,
|
|
472
|
+
mimeType: document.format === "pdf" ? "application/pdf" : null,
|
|
473
|
+
reference: document.invoiceNumber,
|
|
474
|
+
},
|
|
475
|
+
];
|
|
476
|
+
});
|
|
477
|
+
return { documents: resolvedDocuments, payments: paymentHistory, portalDocuments };
|
|
478
|
+
}
|
|
479
|
+
function toCustomerCompanion(row) {
|
|
480
|
+
const metadata = row.metadata ?? null;
|
|
481
|
+
return {
|
|
482
|
+
id: row.id,
|
|
483
|
+
role: row.role,
|
|
484
|
+
name: row.name,
|
|
485
|
+
title: row.title ?? null,
|
|
486
|
+
email: row.email ?? null,
|
|
487
|
+
phone: row.phone ?? null,
|
|
488
|
+
isPrimary: row.isPrimary,
|
|
489
|
+
notes: row.notes ?? null,
|
|
490
|
+
typeKey: getCompanionTypeKey(metadata) ?? null,
|
|
491
|
+
person: getCompanionPersonMetadata({
|
|
492
|
+
...metadata,
|
|
493
|
+
name: row.name,
|
|
494
|
+
}),
|
|
495
|
+
metadata,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function getCompanionLookupKeys(input) {
|
|
499
|
+
const keys = [normalizeCompanionLookupName(input.name)];
|
|
500
|
+
if (input.email) {
|
|
501
|
+
keys.push(`email:${normalizeEmail(input.email)}`);
|
|
502
|
+
}
|
|
503
|
+
if (input.phone) {
|
|
504
|
+
keys.push(`phone:${normalizePhone(input.phone)}`);
|
|
505
|
+
}
|
|
506
|
+
return keys;
|
|
507
|
+
}
|
|
508
|
+
async function getAuthProfileRow(db, userId) {
|
|
509
|
+
const [row] = await db
|
|
510
|
+
.select({
|
|
511
|
+
id: authUser.id,
|
|
512
|
+
email: authUser.email,
|
|
513
|
+
emailVerified: authUser.emailVerified,
|
|
514
|
+
name: authUser.name,
|
|
515
|
+
image: authUser.image,
|
|
516
|
+
firstName: userProfilesTable.firstName,
|
|
517
|
+
lastName: userProfilesTable.lastName,
|
|
518
|
+
avatarUrl: userProfilesTable.avatarUrl,
|
|
519
|
+
locale: userProfilesTable.locale,
|
|
520
|
+
timezone: userProfilesTable.timezone,
|
|
521
|
+
seatingPreference: userProfilesTable.seatingPreference,
|
|
522
|
+
marketingConsent: userProfilesTable.marketingConsent,
|
|
523
|
+
marketingConsentAt: userProfilesTable.marketingConsentAt,
|
|
524
|
+
marketingConsentSource: userProfilesTable.marketingConsentSource,
|
|
525
|
+
notificationDefaults: userProfilesTable.notificationDefaults,
|
|
526
|
+
uiPrefs: userProfilesTable.uiPrefs,
|
|
527
|
+
})
|
|
528
|
+
.from(authUser)
|
|
529
|
+
.leftJoin(userProfilesTable, eq(userProfilesTable.id, authUser.id))
|
|
530
|
+
.where(eq(authUser.id, userId))
|
|
531
|
+
.limit(1);
|
|
532
|
+
return row ?? null;
|
|
533
|
+
}
|
|
534
|
+
async function decryptProfileBlob(envelope, options) {
|
|
535
|
+
if (!envelope || !options?.kms) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const decrypted = await decryptOptionalJsonEnvelope(options.kms, peopleKeyRef, envelope, personPiiBlobPlaintextSchema);
|
|
539
|
+
return decrypted?.text ?? null;
|
|
540
|
+
}
|
|
541
|
+
async function encryptProfileBlob(value, options) {
|
|
542
|
+
if (!options?.kms) {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
if (value === null) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
return encryptOptionalJsonEnvelope(options.kms, peopleKeyRef, { text: value });
|
|
549
|
+
}
|
|
550
|
+
async function decryptDocumentNumber(envelope, options) {
|
|
551
|
+
if (!envelope || !options?.kms) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
const decrypted = await decryptOptionalJsonEnvelope(options.kms, peopleKeyRef, envelope, personDocumentNumberPlaintextSchema);
|
|
555
|
+
return decrypted?.number ?? null;
|
|
556
|
+
}
|
|
557
|
+
async function encryptDocumentNumber(value, options) {
|
|
558
|
+
if (!options?.kms) {
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
if (value == null) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
return encryptOptionalJsonEnvelope(options.kms, peopleKeyRef, { number: value });
|
|
565
|
+
}
|
|
566
|
+
async function projectPersonDocumentToWire(row, options) {
|
|
567
|
+
return {
|
|
568
|
+
id: row.id,
|
|
569
|
+
type: toWireDocumentType(row.type),
|
|
570
|
+
number: await decryptDocumentNumber(row.numberEncrypted, options),
|
|
571
|
+
issuingAuthority: row.issuingAuthority ?? null,
|
|
572
|
+
issuingCountry: row.issuingCountry ?? null,
|
|
573
|
+
issueDate: row.issueDate ?? null,
|
|
574
|
+
expiryDate: row.expiryDate ?? null,
|
|
575
|
+
attachmentId: row.attachmentId ?? null,
|
|
576
|
+
isPrimary: row.isPrimary,
|
|
577
|
+
notes: row.notes ?? null,
|
|
578
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
579
|
+
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
async function getLinkedPersonPiiRow(db, userId) {
|
|
583
|
+
const [row] = await db
|
|
584
|
+
.select({
|
|
585
|
+
id: people.id,
|
|
586
|
+
accessibilityEncrypted: people.accessibilityEncrypted,
|
|
587
|
+
dietaryEncrypted: people.dietaryEncrypted,
|
|
588
|
+
loyaltyEncrypted: people.loyaltyEncrypted,
|
|
589
|
+
insuranceEncrypted: people.insuranceEncrypted,
|
|
590
|
+
})
|
|
591
|
+
.from(people)
|
|
592
|
+
.where(and(eq(people.source, linkedCustomerSource), eq(people.sourceRef, userId)))
|
|
593
|
+
.limit(1);
|
|
594
|
+
return row ?? null;
|
|
595
|
+
}
|
|
596
|
+
async function getLinkedPersonDocuments(db, userId, options) {
|
|
597
|
+
const linked = await resolveLinkedCustomerRecordId(db, userId);
|
|
598
|
+
if (!linked) {
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
const rows = await relationshipsService.listPersonDocuments(db, linked);
|
|
602
|
+
return Promise.all(rows.map((row) => projectPersonDocumentToWire(row, options)));
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Resolves the `crm.people` row linked to this auth user, creating
|
|
606
|
+
* it on first PII write if missing. The seed values mirror what the
|
|
607
|
+
* bootstrap path already produces — first/last name from the auth
|
|
608
|
+
* profile, source/sourceRef pinned to `auth.user`/`userId` so future
|
|
609
|
+
* reads find the same row.
|
|
610
|
+
*/
|
|
611
|
+
async function ensureLinkedPerson(db, userId, authProfile) {
|
|
612
|
+
const existing = await resolveLinkedCustomerRecordId(db, userId);
|
|
613
|
+
if (existing)
|
|
614
|
+
return existing;
|
|
615
|
+
const fallbackFirst = authProfile.firstName ?? authProfile.name.split(" ")[0]?.trim() ?? "Customer";
|
|
616
|
+
const fallbackLast = authProfile.lastName ?? (authProfile.name.split(" ").slice(1).join(" ").trim() || "");
|
|
617
|
+
const created = await relationshipsService.createPerson(db, {
|
|
618
|
+
firstName: fallbackFirst,
|
|
619
|
+
lastName: fallbackLast,
|
|
620
|
+
tags: [],
|
|
621
|
+
status: "active",
|
|
622
|
+
source: linkedCustomerSource,
|
|
623
|
+
sourceRef: userId,
|
|
624
|
+
website: null,
|
|
625
|
+
});
|
|
626
|
+
if (!created) {
|
|
627
|
+
throw new Error("Failed to create linked customer record");
|
|
628
|
+
}
|
|
629
|
+
return created.id;
|
|
630
|
+
}
|
|
631
|
+
async function resolveLinkedCustomerRecordId(db, userId) {
|
|
632
|
+
const [row] = await db
|
|
633
|
+
.select({ id: people.id })
|
|
634
|
+
.from(people)
|
|
635
|
+
.where(and(eq(people.source, linkedCustomerSource), eq(people.sourceRef, userId)))
|
|
636
|
+
.limit(1);
|
|
637
|
+
return row?.id ?? null;
|
|
638
|
+
}
|
|
639
|
+
async function listCustomerRecordCandidatesByEmail(db, email) {
|
|
640
|
+
const normalizedEmail = normalizeEmail(email);
|
|
641
|
+
const rows = await db
|
|
642
|
+
.select({
|
|
643
|
+
id: people.id,
|
|
644
|
+
firstName: people.firstName,
|
|
645
|
+
lastName: people.lastName,
|
|
646
|
+
preferredLanguage: people.preferredLanguage,
|
|
647
|
+
preferredCurrency: people.preferredCurrency,
|
|
648
|
+
dateOfBirth: people.dateOfBirth,
|
|
649
|
+
relation: people.relation,
|
|
650
|
+
status: people.status,
|
|
651
|
+
source: people.source,
|
|
652
|
+
sourceRef: people.sourceRef,
|
|
653
|
+
})
|
|
654
|
+
.from(people)
|
|
655
|
+
.innerJoin(identityContactPoints, and(eq(identityContactPoints.entityType, "person"), eq(identityContactPoints.entityId, people.id), eq(identityContactPoints.kind, "email"), eq(identityContactPoints.normalizedValue, normalizedEmail)))
|
|
656
|
+
.orderBy(desc(people.updatedAt));
|
|
657
|
+
const uniqueRows = new Map();
|
|
658
|
+
for (const row of rows) {
|
|
659
|
+
if (!uniqueRows.has(row.id)) {
|
|
660
|
+
uniqueRows.set(row.id, row);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const candidates = Array.from(uniqueRows.values()).map((row) => ({
|
|
664
|
+
id: row.id,
|
|
665
|
+
firstName: row.firstName,
|
|
666
|
+
lastName: row.lastName,
|
|
667
|
+
preferredLanguage: row.preferredLanguage ?? null,
|
|
668
|
+
preferredCurrency: row.preferredCurrency ?? null,
|
|
669
|
+
dateOfBirth: row.dateOfBirth ?? null,
|
|
670
|
+
email: normalizedEmail,
|
|
671
|
+
phone: null,
|
|
672
|
+
billingAddress: null,
|
|
673
|
+
relation: row.relation ?? null,
|
|
674
|
+
status: row.status,
|
|
675
|
+
claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
|
|
676
|
+
linkable: row.source === linkedCustomerSource ? row.sourceRef == null : row.sourceRef == null,
|
|
677
|
+
}));
|
|
678
|
+
return candidates;
|
|
679
|
+
}
|
|
680
|
+
async function listCustomerRecordCandidatesByPhone(db, phone) {
|
|
681
|
+
const normalizedPhone = normalizePhone(phone);
|
|
682
|
+
const rows = await db
|
|
683
|
+
.select({
|
|
684
|
+
id: people.id,
|
|
685
|
+
firstName: people.firstName,
|
|
686
|
+
lastName: people.lastName,
|
|
687
|
+
preferredLanguage: people.preferredLanguage,
|
|
688
|
+
preferredCurrency: people.preferredCurrency,
|
|
689
|
+
dateOfBirth: people.dateOfBirth,
|
|
690
|
+
relation: people.relation,
|
|
691
|
+
status: people.status,
|
|
692
|
+
source: people.source,
|
|
693
|
+
sourceRef: people.sourceRef,
|
|
694
|
+
})
|
|
695
|
+
.from(people)
|
|
696
|
+
.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))))
|
|
697
|
+
.orderBy(desc(people.updatedAt));
|
|
698
|
+
const uniqueRows = new Map();
|
|
699
|
+
for (const row of rows) {
|
|
700
|
+
if (!uniqueRows.has(row.id)) {
|
|
701
|
+
uniqueRows.set(row.id, row);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return Array.from(uniqueRows.values()).map((row) => ({
|
|
705
|
+
id: row.id,
|
|
706
|
+
firstName: row.firstName,
|
|
707
|
+
lastName: row.lastName,
|
|
708
|
+
preferredLanguage: row.preferredLanguage ?? null,
|
|
709
|
+
preferredCurrency: row.preferredCurrency ?? null,
|
|
710
|
+
dateOfBirth: row.dateOfBirth ?? null,
|
|
711
|
+
email: null,
|
|
712
|
+
phone: normalizedPhone,
|
|
713
|
+
billingAddress: null,
|
|
714
|
+
relation: row.relation ?? null,
|
|
715
|
+
status: row.status,
|
|
716
|
+
claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
|
|
717
|
+
linkable: row.source === linkedCustomerSource ? row.sourceRef == null : row.sourceRef == null,
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
async function getCustomerRecord(db, userId) {
|
|
721
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
722
|
+
if (!personId) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
const [person, addresses] = await Promise.all([
|
|
726
|
+
relationshipsService.getPersonById(db, personId),
|
|
727
|
+
identityService.listAddressesForEntity(db, "person", personId),
|
|
728
|
+
]);
|
|
729
|
+
if (!person) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
const billingAddress = selectPreferredAddress(addresses);
|
|
733
|
+
return {
|
|
734
|
+
id: person.id,
|
|
735
|
+
firstName: person.firstName,
|
|
736
|
+
lastName: person.lastName,
|
|
737
|
+
preferredLanguage: person.preferredLanguage ?? null,
|
|
738
|
+
preferredCurrency: person.preferredCurrency ?? null,
|
|
739
|
+
dateOfBirth: person.dateOfBirth ?? null,
|
|
740
|
+
email: person.email ?? null,
|
|
741
|
+
phone: person.phone ?? null,
|
|
742
|
+
billingAddress: billingAddress ? toCustomerAddress(billingAddress) : null,
|
|
743
|
+
relation: person.relation ?? null,
|
|
744
|
+
status: person.status,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async function upsertCustomerBillingAddress(db, personId, input) {
|
|
748
|
+
const existingAddresses = await identityService.listAddressesForEntity(db, "person", personId);
|
|
749
|
+
const existingAddress = selectPreferredAddress(existingAddresses);
|
|
750
|
+
const merged = {
|
|
751
|
+
label: input.label ?? existingAddress?.label ?? "billing",
|
|
752
|
+
fullText: normalizeNullableString(input.fullText) ?? existingAddress?.fullText ?? null,
|
|
753
|
+
line1: normalizeNullableString(input.line1) ?? existingAddress?.line1 ?? null,
|
|
754
|
+
line2: normalizeNullableString(input.line2) ?? existingAddress?.line2 ?? null,
|
|
755
|
+
city: normalizeNullableString(input.city) ?? existingAddress?.city ?? null,
|
|
756
|
+
region: normalizeNullableString(input.region) ?? existingAddress?.region ?? null,
|
|
757
|
+
postalCode: normalizeNullableString(input.postalCode) ?? existingAddress?.postalCode ?? null,
|
|
758
|
+
country: normalizeNullableString(input.country) ?? existingAddress?.country ?? null,
|
|
759
|
+
isPrimary: input.isPrimary ?? existingAddress?.isPrimary ?? existingAddresses.length === 0,
|
|
760
|
+
};
|
|
761
|
+
if (existingAddress) {
|
|
762
|
+
return identityService.updateAddress(db, existingAddress.id, merged);
|
|
763
|
+
}
|
|
764
|
+
return identityService.createAddress(db, {
|
|
765
|
+
entityType: "person",
|
|
766
|
+
entityId: personId,
|
|
767
|
+
...merged,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
async function getAccessibleBookingIds(db, params) {
|
|
771
|
+
const linkedPersonId = await resolveLinkedCustomerRecordId(db, params.userId);
|
|
772
|
+
const email = params.email?.trim().toLowerCase() ?? null;
|
|
773
|
+
const [directBookingRows, participantPersonRows, participantEmailRows] = await Promise.all([
|
|
774
|
+
linkedPersonId
|
|
775
|
+
? db
|
|
776
|
+
.select({ bookingId: bookings.id })
|
|
777
|
+
.from(bookings)
|
|
778
|
+
.where(eq(bookings.personId, linkedPersonId))
|
|
779
|
+
: Promise.resolve([]),
|
|
780
|
+
linkedPersonId
|
|
781
|
+
? db
|
|
782
|
+
.select({ bookingId: bookingTravelers.bookingId })
|
|
783
|
+
.from(bookingTravelers)
|
|
784
|
+
.where(eq(bookingTravelers.personId, linkedPersonId))
|
|
785
|
+
: Promise.resolve([]),
|
|
786
|
+
// Phone-only users have no email to match on — fall back to linked-person matching only.
|
|
787
|
+
email
|
|
788
|
+
? db
|
|
789
|
+
.select({ bookingId: bookingTravelers.bookingId })
|
|
790
|
+
.from(bookingTravelers)
|
|
791
|
+
// agent-quality: raw-sql reviewed -- owner: customer-portal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
792
|
+
.where(sql `lower(${bookingTravelers.email}) = ${email}`)
|
|
793
|
+
: Promise.resolve([]),
|
|
794
|
+
]);
|
|
795
|
+
return Array.from(new Set([...directBookingRows, ...participantPersonRows, ...participantEmailRows].map((row) => row.bookingId)));
|
|
796
|
+
}
|
|
797
|
+
async function hasBookingAccess(params) {
|
|
798
|
+
const ownershipConditions = [];
|
|
799
|
+
if (params.authEmail) {
|
|
800
|
+
// agent-quality: raw-sql reviewed -- owner: customer-portal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
801
|
+
ownershipConditions.push(sql `lower(${bookingTravelers.email}) = ${params.authEmail}`);
|
|
802
|
+
}
|
|
803
|
+
if (params.linkedPersonId) {
|
|
804
|
+
ownershipConditions.push(eq(bookingTravelers.personId, params.linkedPersonId));
|
|
805
|
+
}
|
|
806
|
+
if (ownershipConditions.length === 0) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
const [participantMatch, bookingMatch] = await Promise.all([
|
|
810
|
+
params.db
|
|
811
|
+
.select({ bookingId: bookingTravelers.bookingId })
|
|
812
|
+
.from(bookingTravelers)
|
|
813
|
+
.where(and(eq(bookingTravelers.bookingId, params.bookingId), or(...ownershipConditions)))
|
|
814
|
+
.limit(1),
|
|
815
|
+
params.linkedPersonId
|
|
816
|
+
? params.db
|
|
817
|
+
.select({ bookingId: bookings.id })
|
|
818
|
+
.from(bookings)
|
|
819
|
+
.where(and(eq(bookings.id, params.bookingId), eq(bookings.personId, params.linkedPersonId)))
|
|
820
|
+
.limit(1)
|
|
821
|
+
: Promise.resolve([]),
|
|
822
|
+
]);
|
|
823
|
+
return Boolean(participantMatch[0] || bookingMatch[0]);
|
|
824
|
+
}
|
|
825
|
+
async function getBookingBillingContact(db, bookingId, customerRecord) {
|
|
826
|
+
const [bookingRows, stateRows, primaryParticipantRows] = await Promise.all([
|
|
827
|
+
db
|
|
828
|
+
.select({
|
|
829
|
+
contactFirstName: bookings.contactFirstName,
|
|
830
|
+
contactLastName: bookings.contactLastName,
|
|
831
|
+
contactEmail: bookings.contactEmail,
|
|
832
|
+
contactPhone: bookings.contactPhone,
|
|
833
|
+
contactCountry: bookings.contactCountry,
|
|
834
|
+
contactRegion: bookings.contactRegion,
|
|
835
|
+
contactCity: bookings.contactCity,
|
|
836
|
+
contactAddressLine1: bookings.contactAddressLine1,
|
|
837
|
+
contactAddressLine2: bookings.contactAddressLine2,
|
|
838
|
+
contactPostalCode: bookings.contactPostalCode,
|
|
839
|
+
})
|
|
840
|
+
.from(bookings)
|
|
841
|
+
.where(eq(bookings.id, bookingId))
|
|
842
|
+
.limit(1),
|
|
843
|
+
db
|
|
844
|
+
.select({ payload: bookingSessionStates.payload })
|
|
845
|
+
.from(bookingSessionStates)
|
|
846
|
+
.where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, bookingWizardStateKey)))
|
|
847
|
+
.limit(1),
|
|
848
|
+
db
|
|
849
|
+
.select({
|
|
850
|
+
firstName: bookingTravelers.firstName,
|
|
851
|
+
lastName: bookingTravelers.lastName,
|
|
852
|
+
email: bookingTravelers.email,
|
|
853
|
+
phone: bookingTravelers.phone,
|
|
854
|
+
})
|
|
855
|
+
.from(bookingTravelers)
|
|
856
|
+
.where(and(eq(bookingTravelers.bookingId, bookingId), eq(bookingTravelers.isPrimary, true)))
|
|
857
|
+
.orderBy(asc(bookingTravelers.createdAt))
|
|
858
|
+
.limit(1),
|
|
859
|
+
]);
|
|
860
|
+
const booking = bookingRows[0] ?? null;
|
|
861
|
+
const stateRow = stateRows[0] ?? null;
|
|
862
|
+
const primaryParticipant = primaryParticipantRows[0] ?? null;
|
|
863
|
+
const sessionBillingContact = resolveBillingContactFromSessionPayload(stateRow?.payload ?? null);
|
|
864
|
+
const billingAddress = customerRecord?.billingAddress ?? null;
|
|
865
|
+
const result = {
|
|
866
|
+
email: booking?.contactEmail ??
|
|
867
|
+
sessionBillingContact?.email ??
|
|
868
|
+
primaryParticipant?.email ??
|
|
869
|
+
customerRecord?.email ??
|
|
870
|
+
null,
|
|
871
|
+
phone: booking?.contactPhone ??
|
|
872
|
+
sessionBillingContact?.phone ??
|
|
873
|
+
primaryParticipant?.phone ??
|
|
874
|
+
customerRecord?.phone ??
|
|
875
|
+
null,
|
|
876
|
+
firstName: booking?.contactFirstName ??
|
|
877
|
+
sessionBillingContact?.firstName ??
|
|
878
|
+
primaryParticipant?.firstName ??
|
|
879
|
+
customerRecord?.firstName ??
|
|
880
|
+
null,
|
|
881
|
+
lastName: booking?.contactLastName ??
|
|
882
|
+
sessionBillingContact?.lastName ??
|
|
883
|
+
primaryParticipant?.lastName ??
|
|
884
|
+
customerRecord?.lastName ??
|
|
885
|
+
null,
|
|
886
|
+
country: booking?.contactCountry ?? sessionBillingContact?.country ?? billingAddress?.country ?? null,
|
|
887
|
+
state: booking?.contactRegion ?? sessionBillingContact?.state ?? billingAddress?.region ?? null,
|
|
888
|
+
city: booking?.contactCity ?? sessionBillingContact?.city ?? billingAddress?.city ?? null,
|
|
889
|
+
address1: booking?.contactAddressLine1 ??
|
|
890
|
+
sessionBillingContact?.address1 ??
|
|
891
|
+
billingAddress?.line1 ??
|
|
892
|
+
null,
|
|
893
|
+
address2: booking?.contactAddressLine2 ??
|
|
894
|
+
sessionBillingContact?.address2 ??
|
|
895
|
+
billingAddress?.line2 ??
|
|
896
|
+
null,
|
|
897
|
+
postal: booking?.contactPostalCode ??
|
|
898
|
+
sessionBillingContact?.postal ??
|
|
899
|
+
billingAddress?.postalCode ??
|
|
900
|
+
null,
|
|
901
|
+
};
|
|
902
|
+
const hasValue = Object.values(result).some((value) => typeof value === "string" && value.length > 0);
|
|
903
|
+
return hasValue ? result : null;
|
|
904
|
+
}
|
|
905
|
+
async function buildBookingDetail(db, bookingId, customerRecord = null, options = {}) {
|
|
906
|
+
const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
|
|
907
|
+
if (!booking) {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
const [participants, items, itemParticipantLinks, documents, fulfillments, legalDocuments, financeData, billingContact,] = await Promise.all([
|
|
911
|
+
db
|
|
912
|
+
.select()
|
|
913
|
+
.from(bookingTravelers)
|
|
914
|
+
.where(eq(bookingTravelers.bookingId, booking.id))
|
|
915
|
+
.orderBy(asc(bookingTravelers.createdAt)),
|
|
916
|
+
db
|
|
917
|
+
.select()
|
|
918
|
+
.from(bookingItems)
|
|
919
|
+
.where(eq(bookingItems.bookingId, booking.id))
|
|
920
|
+
.orderBy(asc(bookingItems.createdAt)),
|
|
921
|
+
db
|
|
922
|
+
.select({
|
|
923
|
+
id: bookingItemTravelers.id,
|
|
924
|
+
bookingItemId: bookingItemTravelers.bookingItemId,
|
|
925
|
+
travelerId: bookingItemTravelers.travelerId,
|
|
926
|
+
role: bookingItemTravelers.role,
|
|
927
|
+
isPrimary: bookingItemTravelers.isPrimary,
|
|
928
|
+
})
|
|
929
|
+
.from(bookingItemTravelers)
|
|
930
|
+
.innerJoin(bookingItems, eq(bookingItems.id, bookingItemTravelers.bookingItemId))
|
|
931
|
+
.where(eq(bookingItems.bookingId, booking.id))
|
|
932
|
+
.orderBy(asc(bookingItemTravelers.createdAt)),
|
|
933
|
+
db
|
|
934
|
+
.select()
|
|
935
|
+
.from(bookingDocuments)
|
|
936
|
+
.where(eq(bookingDocuments.bookingId, booking.id))
|
|
937
|
+
.orderBy(asc(bookingDocuments.createdAt)),
|
|
938
|
+
db
|
|
939
|
+
.select()
|
|
940
|
+
.from(bookingFulfillments)
|
|
941
|
+
.where(eq(bookingFulfillments.bookingId, booking.id))
|
|
942
|
+
.orderBy(asc(bookingFulfillments.createdAt)),
|
|
943
|
+
listLegalDocumentsForBooking(db, booking.id, options),
|
|
944
|
+
getFinanceDataForBooking(db, booking.id, options),
|
|
945
|
+
getBookingBillingContact(db, booking.id, customerRecord),
|
|
946
|
+
]);
|
|
947
|
+
const itemLinksByItemId = new Map();
|
|
948
|
+
for (const link of itemParticipantLinks) {
|
|
949
|
+
const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
|
|
950
|
+
existing.push({
|
|
951
|
+
id: link.id,
|
|
952
|
+
travelerId: link.travelerId,
|
|
953
|
+
role: link.role,
|
|
954
|
+
isPrimary: link.isPrimary,
|
|
955
|
+
});
|
|
956
|
+
itemLinksByItemId.set(link.bookingItemId, existing);
|
|
957
|
+
}
|
|
958
|
+
const unifiedDocuments = [
|
|
959
|
+
...documents.map((document) => ({
|
|
960
|
+
id: document.id,
|
|
961
|
+
source: "booking_document",
|
|
962
|
+
travelerId: document.travelerId ?? null,
|
|
963
|
+
type: document.type,
|
|
964
|
+
fileName: document.fileName,
|
|
965
|
+
fileUrl: document.fileUrl,
|
|
966
|
+
mimeType: null,
|
|
967
|
+
reference: null,
|
|
968
|
+
})),
|
|
969
|
+
...legalDocuments,
|
|
970
|
+
...financeData.portalDocuments,
|
|
971
|
+
];
|
|
972
|
+
const financials = {
|
|
973
|
+
documents: financeData.documents,
|
|
974
|
+
payments: financeData.payments,
|
|
975
|
+
};
|
|
976
|
+
const travelerParticipants = participants.filter((participant) => ["traveler", "occupant", "other"].includes(participant.participantType));
|
|
977
|
+
return customerPortalBookingDetailSchema.parse({
|
|
978
|
+
bookingId: booking.id,
|
|
979
|
+
bookingNumber: booking.bookingNumber,
|
|
980
|
+
status: booking.status,
|
|
981
|
+
sellCurrency: booking.sellCurrency,
|
|
982
|
+
sellAmountCents: booking.sellAmountCents ?? null,
|
|
983
|
+
startDate: normalizeDate(booking.startDate),
|
|
984
|
+
endDate: normalizeDate(booking.endDate),
|
|
985
|
+
pax: booking.pax ?? null,
|
|
986
|
+
confirmedAt: normalizeDateTime(booking.confirmedAt),
|
|
987
|
+
cancelledAt: normalizeDateTime(booking.cancelledAt),
|
|
988
|
+
completedAt: normalizeDateTime(booking.completedAt),
|
|
989
|
+
travelers: travelerParticipants.map((participant) => ({
|
|
990
|
+
id: participant.id,
|
|
991
|
+
participantType: participant.participantType,
|
|
992
|
+
firstName: participant.firstName,
|
|
993
|
+
lastName: participant.lastName,
|
|
994
|
+
isPrimary: participant.isPrimary,
|
|
995
|
+
})),
|
|
996
|
+
items: items.map((item) => ({
|
|
997
|
+
id: item.id,
|
|
998
|
+
title: item.title,
|
|
999
|
+
description: item.description ?? null,
|
|
1000
|
+
itemType: item.itemType,
|
|
1001
|
+
status: item.status,
|
|
1002
|
+
serviceDate: normalizeDate(item.serviceDate),
|
|
1003
|
+
startsAt: normalizeDateTime(item.startsAt),
|
|
1004
|
+
endsAt: normalizeDateTime(item.endsAt),
|
|
1005
|
+
quantity: item.quantity,
|
|
1006
|
+
sellCurrency: item.sellCurrency,
|
|
1007
|
+
unitSellAmountCents: item.unitSellAmountCents ?? null,
|
|
1008
|
+
totalSellAmountCents: item.totalSellAmountCents ?? null,
|
|
1009
|
+
notes: item.notes ?? null,
|
|
1010
|
+
travelerLinks: itemLinksByItemId.get(item.id) ?? [],
|
|
1011
|
+
})),
|
|
1012
|
+
billingContact,
|
|
1013
|
+
documents: unifiedDocuments,
|
|
1014
|
+
financials,
|
|
1015
|
+
fulfillments: fulfillments.map((fulfillment) => ({
|
|
1016
|
+
id: fulfillment.id,
|
|
1017
|
+
bookingItemId: fulfillment.bookingItemId ?? null,
|
|
1018
|
+
travelerId: fulfillment.travelerId ?? null,
|
|
1019
|
+
fulfillmentType: fulfillment.fulfillmentType,
|
|
1020
|
+
deliveryChannel: fulfillment.deliveryChannel,
|
|
1021
|
+
status: fulfillment.status,
|
|
1022
|
+
artifactUrl: fulfillment.artifactUrl ?? null,
|
|
1023
|
+
})),
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
export const publicCustomerPortalService = {
|
|
1027
|
+
async contactExists(db, email) {
|
|
1028
|
+
const normalizedEmail = normalizeEmail(email);
|
|
1029
|
+
const [authAccount, customerCandidates] = await Promise.all([
|
|
1030
|
+
db
|
|
1031
|
+
.select({ id: authUser.id })
|
|
1032
|
+
.from(authUser)
|
|
1033
|
+
// agent-quality: raw-sql reviewed -- owner: customer-portal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
1034
|
+
.where(sql `lower(${authUser.email}) = ${normalizedEmail}`)
|
|
1035
|
+
.limit(1),
|
|
1036
|
+
listCustomerRecordCandidatesByEmail(db, normalizedEmail),
|
|
1037
|
+
]);
|
|
1038
|
+
return {
|
|
1039
|
+
email: normalizedEmail,
|
|
1040
|
+
authAccountExists: Boolean(authAccount[0]),
|
|
1041
|
+
customerRecordExists: customerCandidates.length > 0,
|
|
1042
|
+
linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
|
|
1043
|
+
};
|
|
1044
|
+
},
|
|
1045
|
+
async phoneContactExists(db, phone) {
|
|
1046
|
+
const normalizedPhone = normalizePhone(phone);
|
|
1047
|
+
const [authAccount, customerCandidates] = await Promise.all([
|
|
1048
|
+
db
|
|
1049
|
+
.select({
|
|
1050
|
+
id: authUser.id,
|
|
1051
|
+
phoneNumberVerified: authUser.phoneNumberVerified,
|
|
1052
|
+
})
|
|
1053
|
+
.from(authUser)
|
|
1054
|
+
.where(eq(authUser.phoneNumber, normalizedPhone))
|
|
1055
|
+
.limit(1),
|
|
1056
|
+
listCustomerRecordCandidatesByPhone(db, normalizedPhone),
|
|
1057
|
+
]);
|
|
1058
|
+
return {
|
|
1059
|
+
phone: normalizedPhone,
|
|
1060
|
+
authAccountExists: Boolean(authAccount[0]),
|
|
1061
|
+
authAccountVerified: Boolean(authAccount[0]?.phoneNumberVerified),
|
|
1062
|
+
customerRecordExists: customerCandidates.length > 0,
|
|
1063
|
+
linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
|
|
1064
|
+
};
|
|
1065
|
+
},
|
|
1066
|
+
async getProfile(db, userId) {
|
|
1067
|
+
return this.getProfileWithOptions(db, userId);
|
|
1068
|
+
},
|
|
1069
|
+
async getProfileWithOptions(db, userId, options) {
|
|
1070
|
+
const [authProfile, customerRecord] = await Promise.all([
|
|
1071
|
+
getAuthProfileRow(db, userId),
|
|
1072
|
+
getCustomerRecord(db, userId),
|
|
1073
|
+
]);
|
|
1074
|
+
if (!authProfile) {
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
const linkedPerson = await getLinkedPersonPiiRow(db, userId);
|
|
1078
|
+
const [accessibility, dietary, loyalty, insurance] = await Promise.all([
|
|
1079
|
+
decryptProfileBlob(linkedPerson?.accessibilityEncrypted, options),
|
|
1080
|
+
decryptProfileBlob(linkedPerson?.dietaryEncrypted, options),
|
|
1081
|
+
decryptProfileBlob(linkedPerson?.loyaltyEncrypted, options),
|
|
1082
|
+
decryptProfileBlob(linkedPerson?.insuranceEncrypted, options),
|
|
1083
|
+
]);
|
|
1084
|
+
const billingAddress = customerRecord?.billingAddress ?? null;
|
|
1085
|
+
return {
|
|
1086
|
+
userId: authProfile.id,
|
|
1087
|
+
email: authProfile.email,
|
|
1088
|
+
emailVerified: authProfile.emailVerified,
|
|
1089
|
+
firstName: authProfile.firstName ?? null,
|
|
1090
|
+
middleName: deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName),
|
|
1091
|
+
lastName: authProfile.lastName ?? null,
|
|
1092
|
+
avatarUrl: authProfile.avatarUrl ?? authProfile.image ?? null,
|
|
1093
|
+
locale: authProfile.locale ?? "en",
|
|
1094
|
+
timezone: authProfile.timezone ?? null,
|
|
1095
|
+
seatingPreference: authProfile.seatingPreference ?? null,
|
|
1096
|
+
dateOfBirth: customerRecord?.dateOfBirth ?? null,
|
|
1097
|
+
address: billingAddress
|
|
1098
|
+
? {
|
|
1099
|
+
country: billingAddress.country,
|
|
1100
|
+
state: billingAddress.region,
|
|
1101
|
+
city: billingAddress.city,
|
|
1102
|
+
postalCode: billingAddress.postalCode,
|
|
1103
|
+
addressLine1: billingAddress.line1,
|
|
1104
|
+
addressLine2: billingAddress.line2,
|
|
1105
|
+
}
|
|
1106
|
+
: null,
|
|
1107
|
+
accessibility,
|
|
1108
|
+
dietary,
|
|
1109
|
+
loyalty,
|
|
1110
|
+
insurance,
|
|
1111
|
+
marketingConsent: authProfile.marketingConsent ?? false,
|
|
1112
|
+
marketingConsentAt: normalizeDateTime(authProfile.marketingConsentAt),
|
|
1113
|
+
marketingConsentSource: authProfile.marketingConsentSource ?? null,
|
|
1114
|
+
notificationDefaults: authProfile.notificationDefaults ?? null,
|
|
1115
|
+
uiPrefs: authProfile.uiPrefs ?? null,
|
|
1116
|
+
customerRecord,
|
|
1117
|
+
};
|
|
1118
|
+
},
|
|
1119
|
+
async updateProfile(db, userId, input) {
|
|
1120
|
+
return this.updateProfileWithOptions(db, userId, input);
|
|
1121
|
+
},
|
|
1122
|
+
async updateProfileWithOptions(db, userId, input, options) {
|
|
1123
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1124
|
+
if (!authProfile) {
|
|
1125
|
+
return { error: "not_found" };
|
|
1126
|
+
}
|
|
1127
|
+
const customerRecordId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1128
|
+
if (input.customerRecord && !customerRecordId) {
|
|
1129
|
+
return { error: "customer_record_required" };
|
|
1130
|
+
}
|
|
1131
|
+
const existingMiddleName = deriveMiddleName(authProfile.name, authProfile.firstName, authProfile.lastName);
|
|
1132
|
+
const nextFirstName = input.firstName ?? authProfile.firstName ?? null;
|
|
1133
|
+
const nextMiddleName = input.middleName ?? existingMiddleName;
|
|
1134
|
+
const nextLastName = input.lastName ?? authProfile.lastName ?? null;
|
|
1135
|
+
const nextDisplayName = [nextFirstName, nextMiddleName, nextLastName]
|
|
1136
|
+
.filter(Boolean)
|
|
1137
|
+
.join(" ")
|
|
1138
|
+
.trim();
|
|
1139
|
+
const nextMarketingConsent = resolveMarketingConsentState({
|
|
1140
|
+
currentConsent: authProfile.marketingConsent,
|
|
1141
|
+
currentConsentAt: authProfile.marketingConsentAt,
|
|
1142
|
+
currentConsentSource: authProfile.marketingConsentSource,
|
|
1143
|
+
nextConsent: input.marketingConsent,
|
|
1144
|
+
nextConsentSource: input.marketingConsentSource,
|
|
1145
|
+
});
|
|
1146
|
+
const nextDateOfBirth = input.dateOfBirth !== undefined ? input.dateOfBirth : undefined;
|
|
1147
|
+
const nextAddressRecord = input.address !== undefined
|
|
1148
|
+
? {
|
|
1149
|
+
billingAddress: {
|
|
1150
|
+
line1: input.address.addressLine1,
|
|
1151
|
+
line2: input.address.addressLine2,
|
|
1152
|
+
city: input.address.city,
|
|
1153
|
+
region: input.address.state,
|
|
1154
|
+
postalCode: input.address.postalCode,
|
|
1155
|
+
country: input.address.country,
|
|
1156
|
+
},
|
|
1157
|
+
}
|
|
1158
|
+
: undefined;
|
|
1159
|
+
await db
|
|
1160
|
+
.insert(userProfilesTable)
|
|
1161
|
+
.values({
|
|
1162
|
+
id: userId,
|
|
1163
|
+
firstName: nextFirstName,
|
|
1164
|
+
lastName: nextLastName,
|
|
1165
|
+
avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
|
|
1166
|
+
locale: input.locale ?? authProfile.locale ?? "en",
|
|
1167
|
+
timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
|
|
1168
|
+
seatingPreference: input.seatingPreference !== undefined
|
|
1169
|
+
? input.seatingPreference
|
|
1170
|
+
: (authProfile.seatingPreference ?? null),
|
|
1171
|
+
marketingConsent: nextMarketingConsent.marketingConsent,
|
|
1172
|
+
marketingConsentAt: nextMarketingConsent.marketingConsentAt,
|
|
1173
|
+
marketingConsentSource: nextMarketingConsent.marketingConsentSource,
|
|
1174
|
+
notificationDefaults: input.notificationDefaults !== undefined
|
|
1175
|
+
? input.notificationDefaults
|
|
1176
|
+
: (authProfile.notificationDefaults ?? {}),
|
|
1177
|
+
uiPrefs: input.uiPrefs !== undefined
|
|
1178
|
+
? input.uiPrefs
|
|
1179
|
+
: (authProfile.uiPrefs ?? {}),
|
|
1180
|
+
})
|
|
1181
|
+
.onConflictDoUpdate({
|
|
1182
|
+
target: userProfilesTable.id,
|
|
1183
|
+
set: {
|
|
1184
|
+
firstName: nextFirstName,
|
|
1185
|
+
lastName: nextLastName,
|
|
1186
|
+
avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
|
|
1187
|
+
locale: input.locale ?? authProfile.locale ?? "en",
|
|
1188
|
+
timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
|
|
1189
|
+
seatingPreference: input.seatingPreference !== undefined
|
|
1190
|
+
? input.seatingPreference
|
|
1191
|
+
: (authProfile.seatingPreference ?? null),
|
|
1192
|
+
marketingConsent: nextMarketingConsent.marketingConsent,
|
|
1193
|
+
marketingConsentAt: nextMarketingConsent.marketingConsentAt,
|
|
1194
|
+
marketingConsentSource: nextMarketingConsent.marketingConsentSource,
|
|
1195
|
+
notificationDefaults: input.notificationDefaults !== undefined
|
|
1196
|
+
? input.notificationDefaults
|
|
1197
|
+
: (authProfile.notificationDefaults ?? {}),
|
|
1198
|
+
uiPrefs: input.uiPrefs !== undefined
|
|
1199
|
+
? input.uiPrefs
|
|
1200
|
+
: (authProfile.uiPrefs ?? {}),
|
|
1201
|
+
updatedAt: new Date(),
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
const piiUpdates = {};
|
|
1205
|
+
if (input.accessibility !== undefined) {
|
|
1206
|
+
const enc = await encryptProfileBlob(input.accessibility, options);
|
|
1207
|
+
if (enc !== undefined)
|
|
1208
|
+
piiUpdates.accessibilityEncrypted = enc;
|
|
1209
|
+
}
|
|
1210
|
+
if (input.dietary !== undefined) {
|
|
1211
|
+
const enc = await encryptProfileBlob(input.dietary, options);
|
|
1212
|
+
if (enc !== undefined)
|
|
1213
|
+
piiUpdates.dietaryEncrypted = enc;
|
|
1214
|
+
}
|
|
1215
|
+
if (input.loyalty !== undefined) {
|
|
1216
|
+
const enc = await encryptProfileBlob(input.loyalty, options);
|
|
1217
|
+
if (enc !== undefined)
|
|
1218
|
+
piiUpdates.loyaltyEncrypted = enc;
|
|
1219
|
+
}
|
|
1220
|
+
if (input.insurance !== undefined) {
|
|
1221
|
+
const enc = await encryptProfileBlob(input.insurance, options);
|
|
1222
|
+
if (enc !== undefined)
|
|
1223
|
+
piiUpdates.insuranceEncrypted = enc;
|
|
1224
|
+
}
|
|
1225
|
+
if (Object.keys(piiUpdates).length > 0) {
|
|
1226
|
+
const personId = await ensureLinkedPerson(db, userId, authProfile);
|
|
1227
|
+
await db
|
|
1228
|
+
.update(people)
|
|
1229
|
+
.set({ ...piiUpdates, updatedAt: new Date() })
|
|
1230
|
+
.where(eq(people.id, personId));
|
|
1231
|
+
}
|
|
1232
|
+
await db
|
|
1233
|
+
.update(authUser)
|
|
1234
|
+
.set({
|
|
1235
|
+
name: nextDisplayName || authProfile.name,
|
|
1236
|
+
image: input.avatarUrl !== undefined ? input.avatarUrl : (authProfile.image ?? null),
|
|
1237
|
+
updatedAt: new Date(),
|
|
1238
|
+
})
|
|
1239
|
+
.where(eq(authUser.id, userId));
|
|
1240
|
+
if (customerRecordId) {
|
|
1241
|
+
const nextCustomerRecord = input.customerRecord !== undefined ||
|
|
1242
|
+
nextDateOfBirth !== undefined ||
|
|
1243
|
+
nextAddressRecord !== undefined
|
|
1244
|
+
? {
|
|
1245
|
+
...(input.customerRecord ?? {}),
|
|
1246
|
+
...(nextDateOfBirth !== undefined ? { dateOfBirth: nextDateOfBirth } : {}),
|
|
1247
|
+
...(nextAddressRecord ?? {}),
|
|
1248
|
+
}
|
|
1249
|
+
: undefined;
|
|
1250
|
+
if (nextCustomerRecord || input.firstName !== undefined || input.lastName !== undefined) {
|
|
1251
|
+
if (nextCustomerRecord?.billingAddress !== undefined) {
|
|
1252
|
+
await upsertCustomerBillingAddress(db, customerRecordId, nextCustomerRecord.billingAddress);
|
|
1253
|
+
}
|
|
1254
|
+
await relationshipsService.updatePerson(db, customerRecordId, {
|
|
1255
|
+
...(input.firstName !== undefined ? { firstName: input.firstName ?? "" } : {}),
|
|
1256
|
+
...(input.lastName !== undefined ? { lastName: input.lastName ?? "" } : {}),
|
|
1257
|
+
...(nextCustomerRecord?.preferredLanguage !== undefined
|
|
1258
|
+
? { preferredLanguage: nextCustomerRecord.preferredLanguage }
|
|
1259
|
+
: {}),
|
|
1260
|
+
...(nextCustomerRecord?.preferredCurrency !== undefined
|
|
1261
|
+
? { preferredCurrency: nextCustomerRecord.preferredCurrency }
|
|
1262
|
+
: {}),
|
|
1263
|
+
...(nextCustomerRecord?.dateOfBirth !== undefined
|
|
1264
|
+
? { dateOfBirth: nextCustomerRecord.dateOfBirth }
|
|
1265
|
+
: {}),
|
|
1266
|
+
...(nextCustomerRecord?.phone !== undefined ? { phone: nextCustomerRecord.phone } : {}),
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const profile = await this.getProfileWithOptions(db, userId, options);
|
|
1271
|
+
if (!profile) {
|
|
1272
|
+
return { error: "not_found" };
|
|
1273
|
+
}
|
|
1274
|
+
return { profile };
|
|
1275
|
+
},
|
|
1276
|
+
async bootstrap(db, userId, input) {
|
|
1277
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1278
|
+
if (!authProfile) {
|
|
1279
|
+
return { error: "not_found" };
|
|
1280
|
+
}
|
|
1281
|
+
const linkedCustomerRecordId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1282
|
+
if (linkedCustomerRecordId) {
|
|
1283
|
+
const profile = await this.getProfile(db, userId);
|
|
1284
|
+
return {
|
|
1285
|
+
status: "already_linked",
|
|
1286
|
+
profile,
|
|
1287
|
+
candidates: [],
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
// Phone-only signups have no email; email-keyed candidate
|
|
1291
|
+
// matching simply finds zero candidates and the path falls
|
|
1292
|
+
// through to creating a fresh `crm.people` row when allowed.
|
|
1293
|
+
const normalizedEmail = authProfile.email ? normalizeEmail(authProfile.email) : null;
|
|
1294
|
+
const nextFirstName = input.firstName ?? authProfile.firstName ?? authProfile.name.split(" ")[0] ?? "Customer";
|
|
1295
|
+
const nextLastName = input.lastName ?? authProfile.lastName ?? authProfile.name.split(" ").slice(1).join(" ") ?? "";
|
|
1296
|
+
if (input.marketingConsent !== undefined || input.marketingConsentSource !== undefined) {
|
|
1297
|
+
const nextMarketingConsent = resolveMarketingConsentState({
|
|
1298
|
+
currentConsent: authProfile.marketingConsent,
|
|
1299
|
+
currentConsentAt: authProfile.marketingConsentAt,
|
|
1300
|
+
currentConsentSource: authProfile.marketingConsentSource,
|
|
1301
|
+
nextConsent: input.marketingConsent,
|
|
1302
|
+
nextConsentSource: input.marketingConsentSource,
|
|
1303
|
+
});
|
|
1304
|
+
await db
|
|
1305
|
+
.insert(userProfilesTable)
|
|
1306
|
+
.values({
|
|
1307
|
+
id: userId,
|
|
1308
|
+
marketingConsent: nextMarketingConsent.marketingConsent,
|
|
1309
|
+
marketingConsentAt: nextMarketingConsent.marketingConsentAt,
|
|
1310
|
+
marketingConsentSource: nextMarketingConsent.marketingConsentSource,
|
|
1311
|
+
})
|
|
1312
|
+
.onConflictDoUpdate({
|
|
1313
|
+
target: userProfilesTable.id,
|
|
1314
|
+
set: {
|
|
1315
|
+
marketingConsent: nextMarketingConsent.marketingConsent,
|
|
1316
|
+
marketingConsentAt: nextMarketingConsent.marketingConsentAt,
|
|
1317
|
+
marketingConsentSource: nextMarketingConsent.marketingConsentSource,
|
|
1318
|
+
updatedAt: new Date(),
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
if (input.customerRecordId) {
|
|
1323
|
+
const person = await relationshipsService.getPersonById(db, input.customerRecordId);
|
|
1324
|
+
if (!person) {
|
|
1325
|
+
return { error: "customer_record_not_found" };
|
|
1326
|
+
}
|
|
1327
|
+
if (person.source === linkedCustomerSource &&
|
|
1328
|
+
person.sourceRef &&
|
|
1329
|
+
person.sourceRef !== userId) {
|
|
1330
|
+
return { error: "customer_record_claimed" };
|
|
1331
|
+
}
|
|
1332
|
+
const updated = await relationshipsService.updatePerson(db, input.customerRecordId, {
|
|
1333
|
+
source: linkedCustomerSource,
|
|
1334
|
+
sourceRef: userId,
|
|
1335
|
+
...(input.firstName !== undefined ? { firstName: nextFirstName } : {}),
|
|
1336
|
+
...(input.lastName !== undefined ? { lastName: nextLastName } : {}),
|
|
1337
|
+
...(input.customerRecord?.preferredLanguage !== undefined
|
|
1338
|
+
? { preferredLanguage: input.customerRecord.preferredLanguage }
|
|
1339
|
+
: {}),
|
|
1340
|
+
...(input.customerRecord?.preferredCurrency !== undefined
|
|
1341
|
+
? { preferredCurrency: input.customerRecord.preferredCurrency }
|
|
1342
|
+
: {}),
|
|
1343
|
+
...(input.customerRecord?.dateOfBirth !== undefined
|
|
1344
|
+
? { dateOfBirth: input.customerRecord.dateOfBirth }
|
|
1345
|
+
: {}),
|
|
1346
|
+
...(input.customerRecord?.phone !== undefined ? { phone: input.customerRecord.phone } : {}),
|
|
1347
|
+
});
|
|
1348
|
+
if (!updated) {
|
|
1349
|
+
return { error: "customer_record_not_found" };
|
|
1350
|
+
}
|
|
1351
|
+
if (input.customerRecord?.billingAddress) {
|
|
1352
|
+
await upsertCustomerBillingAddress(db, input.customerRecordId, input.customerRecord.billingAddress);
|
|
1353
|
+
}
|
|
1354
|
+
const profile = await this.getProfile(db, userId);
|
|
1355
|
+
return {
|
|
1356
|
+
status: "linked_existing_customer",
|
|
1357
|
+
profile,
|
|
1358
|
+
candidates: [],
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
const customerCandidates = normalizedEmail
|
|
1362
|
+
? await listCustomerRecordCandidatesByEmail(db, normalizedEmail)
|
|
1363
|
+
: [];
|
|
1364
|
+
const selectableCandidates = customerCandidates.filter((candidate) => !candidate.claimedByAnotherUser);
|
|
1365
|
+
if (selectableCandidates.length > 0) {
|
|
1366
|
+
return {
|
|
1367
|
+
status: "customer_selection_required",
|
|
1368
|
+
profile: null,
|
|
1369
|
+
candidates: selectableCandidates,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
if (!input.createCustomerIfMissing) {
|
|
1373
|
+
return {
|
|
1374
|
+
status: "customer_selection_required",
|
|
1375
|
+
profile: null,
|
|
1376
|
+
candidates: [],
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
const created = await relationshipsService.createPerson(db, {
|
|
1380
|
+
firstName: nextFirstName,
|
|
1381
|
+
lastName: nextLastName || "Customer",
|
|
1382
|
+
preferredLanguage: input.customerRecord?.preferredLanguage ?? authProfile.locale ?? null,
|
|
1383
|
+
preferredCurrency: input.customerRecord?.preferredCurrency ?? null,
|
|
1384
|
+
dateOfBirth: input.customerRecord?.dateOfBirth ?? null,
|
|
1385
|
+
relation: "client",
|
|
1386
|
+
status: "active",
|
|
1387
|
+
source: linkedCustomerSource,
|
|
1388
|
+
sourceRef: userId,
|
|
1389
|
+
tags: [],
|
|
1390
|
+
email: normalizedEmail,
|
|
1391
|
+
phone: input.customerRecord?.phone ?? null,
|
|
1392
|
+
website: null,
|
|
1393
|
+
});
|
|
1394
|
+
if (!created) {
|
|
1395
|
+
return { error: "not_found" };
|
|
1396
|
+
}
|
|
1397
|
+
if (input.customerRecord?.billingAddress) {
|
|
1398
|
+
await upsertCustomerBillingAddress(db, created.id, input.customerRecord.billingAddress);
|
|
1399
|
+
}
|
|
1400
|
+
const profile = await this.getProfile(db, userId);
|
|
1401
|
+
return {
|
|
1402
|
+
status: "created_customer",
|
|
1403
|
+
profile,
|
|
1404
|
+
candidates: [],
|
|
1405
|
+
};
|
|
1406
|
+
},
|
|
1407
|
+
async listCompanions(db, userId) {
|
|
1408
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1409
|
+
if (!personId) {
|
|
1410
|
+
return [];
|
|
1411
|
+
}
|
|
1412
|
+
const rows = await identityService.listNamedContactsForEntity(db, "person", personId);
|
|
1413
|
+
return rows
|
|
1414
|
+
.filter((row) => (row.metadata?.kind ?? null) ===
|
|
1415
|
+
companionMetadataKind)
|
|
1416
|
+
.map(toCustomerCompanion);
|
|
1417
|
+
},
|
|
1418
|
+
async importBookingTravelersAsCompanions(db, userId, input) {
|
|
1419
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1420
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1421
|
+
if (!authProfile || !personId) {
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
const accessibleBookingIds = await getAccessibleBookingIds(db, {
|
|
1425
|
+
userId,
|
|
1426
|
+
email: authProfile.email,
|
|
1427
|
+
});
|
|
1428
|
+
const targetBookingIds = input.bookingIds?.filter((bookingId) => accessibleBookingIds.includes(bookingId)) ??
|
|
1429
|
+
accessibleBookingIds;
|
|
1430
|
+
if (targetBookingIds.length === 0) {
|
|
1431
|
+
return { created: [], skippedCount: 0 };
|
|
1432
|
+
}
|
|
1433
|
+
const [existingCompanionRows, participantRows, staffAssignmentRows] = await Promise.all([
|
|
1434
|
+
identityService.listNamedContactsForEntity(db, "person", personId),
|
|
1435
|
+
db
|
|
1436
|
+
.select()
|
|
1437
|
+
.from(bookingTravelers)
|
|
1438
|
+
.where(inArray(bookingTravelers.bookingId, targetBookingIds))
|
|
1439
|
+
.orderBy(asc(bookingTravelers.createdAt)),
|
|
1440
|
+
db
|
|
1441
|
+
.select()
|
|
1442
|
+
.from(bookingStaffAssignments)
|
|
1443
|
+
.where(inArray(bookingStaffAssignments.bookingId, targetBookingIds))
|
|
1444
|
+
.orderBy(asc(bookingStaffAssignments.createdAt)),
|
|
1445
|
+
]);
|
|
1446
|
+
const existingKeys = new Set(existingCompanionRows
|
|
1447
|
+
.filter((row) => (row.metadata?.kind ?? null) ===
|
|
1448
|
+
companionMetadataKind)
|
|
1449
|
+
.flatMap((row) => getCompanionLookupKeys({
|
|
1450
|
+
name: row.name,
|
|
1451
|
+
email: row.email,
|
|
1452
|
+
phone: row.phone,
|
|
1453
|
+
})));
|
|
1454
|
+
let skippedCount = 0;
|
|
1455
|
+
const created = [];
|
|
1456
|
+
const distinctStaffAssignmentKeys = new Set();
|
|
1457
|
+
for (const assignment of staffAssignmentRows) {
|
|
1458
|
+
distinctStaffAssignmentKeys.add(JSON.stringify([
|
|
1459
|
+
assignment.bookingId,
|
|
1460
|
+
assignment.personId ?? null,
|
|
1461
|
+
assignment.firstName,
|
|
1462
|
+
assignment.lastName,
|
|
1463
|
+
assignment.email ?? null,
|
|
1464
|
+
assignment.phone ?? null,
|
|
1465
|
+
]));
|
|
1466
|
+
}
|
|
1467
|
+
skippedCount += distinctStaffAssignmentKeys.size;
|
|
1468
|
+
for (const participant of participantRows) {
|
|
1469
|
+
const name = `${participant.firstName} ${participant.lastName}`.trim();
|
|
1470
|
+
if (!name) {
|
|
1471
|
+
skippedCount += 1;
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
const email = normalizeNullableString(participant.email);
|
|
1475
|
+
const phone = normalizeNullableString(participant.phone);
|
|
1476
|
+
const lookupKeys = getCompanionLookupKeys({ name, email, phone });
|
|
1477
|
+
if (lookupKeys.some((key) => existingKeys.has(key))) {
|
|
1478
|
+
skippedCount += 1;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const row = await identityService.createNamedContact(db, {
|
|
1482
|
+
entityType: "person",
|
|
1483
|
+
entityId: personId,
|
|
1484
|
+
role: "general",
|
|
1485
|
+
name,
|
|
1486
|
+
title: null,
|
|
1487
|
+
email,
|
|
1488
|
+
phone,
|
|
1489
|
+
isPrimary: false,
|
|
1490
|
+
notes: normalizeNullableString(participant.notes),
|
|
1491
|
+
metadata: buildStoredCompanionMetadata({
|
|
1492
|
+
metadata: {
|
|
1493
|
+
source: "booking_participant_import",
|
|
1494
|
+
bookingId: participant.bookingId,
|
|
1495
|
+
travelerId: participant.id,
|
|
1496
|
+
participantType: participant.participantType,
|
|
1497
|
+
travelerCategory: participant.travelerCategory ?? null,
|
|
1498
|
+
},
|
|
1499
|
+
person: {
|
|
1500
|
+
firstName: participant.firstName,
|
|
1501
|
+
lastName: participant.lastName,
|
|
1502
|
+
},
|
|
1503
|
+
}),
|
|
1504
|
+
});
|
|
1505
|
+
if (!row) {
|
|
1506
|
+
skippedCount += 1;
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
created.push(toCustomerCompanion(row));
|
|
1510
|
+
for (const key of lookupKeys) {
|
|
1511
|
+
existingKeys.add(key);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return { created, skippedCount };
|
|
1515
|
+
},
|
|
1516
|
+
async importBookingParticipantsAsCompanions(db, userId, input) {
|
|
1517
|
+
return this.importBookingTravelersAsCompanions(db, userId, input);
|
|
1518
|
+
},
|
|
1519
|
+
async createCompanion(db, userId, input) {
|
|
1520
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1521
|
+
if (!personId) {
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
const row = await identityService.createNamedContact(db, {
|
|
1525
|
+
entityType: "person",
|
|
1526
|
+
entityId: personId,
|
|
1527
|
+
role: input.role,
|
|
1528
|
+
name: input.name,
|
|
1529
|
+
title: input.title ?? null,
|
|
1530
|
+
email: normalizeNullableString(input.email),
|
|
1531
|
+
phone: normalizeNullableString(input.phone),
|
|
1532
|
+
isPrimary: input.isPrimary,
|
|
1533
|
+
notes: normalizeNullableString(input.notes),
|
|
1534
|
+
metadata: buildStoredCompanionMetadata({
|
|
1535
|
+
metadata: input.metadata ?? undefined,
|
|
1536
|
+
typeKey: input.typeKey,
|
|
1537
|
+
person: input.person,
|
|
1538
|
+
}),
|
|
1539
|
+
});
|
|
1540
|
+
return row ? toCustomerCompanion(row) : null;
|
|
1541
|
+
},
|
|
1542
|
+
async updateCompanion(db, userId, companionId, input) {
|
|
1543
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1544
|
+
if (!personId) {
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
const existing = await identityService.getNamedContactById(db, companionId);
|
|
1548
|
+
if (!existing ||
|
|
1549
|
+
existing.entityType !== "person" ||
|
|
1550
|
+
existing.entityId !== personId ||
|
|
1551
|
+
(existing.metadata?.kind ?? null) !==
|
|
1552
|
+
companionMetadataKind) {
|
|
1553
|
+
return "forbidden";
|
|
1554
|
+
}
|
|
1555
|
+
const row = await identityService.updateNamedContact(db, companionId, {
|
|
1556
|
+
...(input.role !== undefined ? { role: input.role } : {}),
|
|
1557
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
1558
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
1559
|
+
...(input.email !== undefined ? { email: normalizeNullableString(input.email) } : {}),
|
|
1560
|
+
...(input.phone !== undefined ? { phone: normalizeNullableString(input.phone) } : {}),
|
|
1561
|
+
...(input.isPrimary !== undefined ? { isPrimary: input.isPrimary } : {}),
|
|
1562
|
+
...(input.notes !== undefined ? { notes: normalizeNullableString(input.notes) } : {}),
|
|
1563
|
+
...(input.metadata !== undefined || input.typeKey !== undefined || input.person !== undefined
|
|
1564
|
+
? {
|
|
1565
|
+
metadata: buildStoredCompanionMetadata({
|
|
1566
|
+
existingMetadata: existing.metadata ?? undefined,
|
|
1567
|
+
...(input.metadata !== undefined
|
|
1568
|
+
? { metadata: input.metadata ?? null }
|
|
1569
|
+
: {}),
|
|
1570
|
+
...(input.typeKey !== undefined ? { typeKey: input.typeKey } : {}),
|
|
1571
|
+
...(input.person !== undefined ? { person: input.person } : {}),
|
|
1572
|
+
}),
|
|
1573
|
+
}
|
|
1574
|
+
: {}),
|
|
1575
|
+
});
|
|
1576
|
+
return row ? toCustomerCompanion(row) : null;
|
|
1577
|
+
},
|
|
1578
|
+
async deleteCompanion(db, userId, companionId) {
|
|
1579
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1580
|
+
if (!personId) {
|
|
1581
|
+
return "not_found";
|
|
1582
|
+
}
|
|
1583
|
+
const existing = await identityService.getNamedContactById(db, companionId);
|
|
1584
|
+
if (!existing) {
|
|
1585
|
+
return "not_found";
|
|
1586
|
+
}
|
|
1587
|
+
if (existing.entityType !== "person" ||
|
|
1588
|
+
existing.entityId !== personId ||
|
|
1589
|
+
(existing.metadata?.kind ?? null) !==
|
|
1590
|
+
companionMetadataKind) {
|
|
1591
|
+
return "forbidden";
|
|
1592
|
+
}
|
|
1593
|
+
await identityService.deleteNamedContact(db, companionId);
|
|
1594
|
+
return "deleted";
|
|
1595
|
+
},
|
|
1596
|
+
async listBookings(db, userId) {
|
|
1597
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1598
|
+
if (!authProfile) {
|
|
1599
|
+
return null;
|
|
1600
|
+
}
|
|
1601
|
+
const bookingIds = await getAccessibleBookingIds(db, { userId, email: authProfile.email });
|
|
1602
|
+
if (bookingIds.length === 0) {
|
|
1603
|
+
return [];
|
|
1604
|
+
}
|
|
1605
|
+
const [bookingRows, participantRows, itemRows, invoiceRows] = await Promise.all([
|
|
1606
|
+
db
|
|
1607
|
+
.select()
|
|
1608
|
+
.from(bookings)
|
|
1609
|
+
.where(inArray(bookings.id, bookingIds))
|
|
1610
|
+
.orderBy(desc(bookings.createdAt)),
|
|
1611
|
+
db
|
|
1612
|
+
.select()
|
|
1613
|
+
.from(bookingTravelers)
|
|
1614
|
+
.where(inArray(bookingTravelers.bookingId, bookingIds))
|
|
1615
|
+
.orderBy(asc(bookingTravelers.createdAt)),
|
|
1616
|
+
db
|
|
1617
|
+
.select({
|
|
1618
|
+
bookingId: bookingItems.bookingId,
|
|
1619
|
+
title: bookingItems.title,
|
|
1620
|
+
itemType: bookingItems.itemType,
|
|
1621
|
+
createdAt: bookingItems.createdAt,
|
|
1622
|
+
})
|
|
1623
|
+
.from(bookingItems)
|
|
1624
|
+
.where(inArray(bookingItems.bookingId, bookingIds))
|
|
1625
|
+
.orderBy(asc(bookingItems.createdAt)),
|
|
1626
|
+
db
|
|
1627
|
+
.select({
|
|
1628
|
+
bookingId: invoices.bookingId,
|
|
1629
|
+
invoiceType: invoices.invoiceType,
|
|
1630
|
+
status: invoices.status,
|
|
1631
|
+
paidCents: invoices.paidCents,
|
|
1632
|
+
balanceDueCents: invoices.balanceDueCents,
|
|
1633
|
+
createdAt: invoices.createdAt,
|
|
1634
|
+
})
|
|
1635
|
+
.from(invoices)
|
|
1636
|
+
.where(inArray(invoices.bookingId, bookingIds))
|
|
1637
|
+
.orderBy(desc(invoices.createdAt)),
|
|
1638
|
+
]);
|
|
1639
|
+
const participantsByBookingId = new Map();
|
|
1640
|
+
for (const participant of participantRows) {
|
|
1641
|
+
const bucket = participantsByBookingId.get(participant.bookingId) ?? [];
|
|
1642
|
+
bucket.push(participant);
|
|
1643
|
+
participantsByBookingId.set(participant.bookingId, bucket);
|
|
1644
|
+
}
|
|
1645
|
+
const itemsByBookingId = new Map();
|
|
1646
|
+
for (const item of itemRows) {
|
|
1647
|
+
const bucket = itemsByBookingId.get(item.bookingId) ?? [];
|
|
1648
|
+
bucket.push(item);
|
|
1649
|
+
itemsByBookingId.set(item.bookingId, bucket);
|
|
1650
|
+
}
|
|
1651
|
+
const invoicesByBookingId = new Map();
|
|
1652
|
+
for (const invoice of invoiceRows) {
|
|
1653
|
+
const bucket = invoicesByBookingId.get(invoice.bookingId) ?? [];
|
|
1654
|
+
bucket.push(invoice);
|
|
1655
|
+
invoicesByBookingId.set(invoice.bookingId, bucket);
|
|
1656
|
+
}
|
|
1657
|
+
return bookingRows.map((booking) => {
|
|
1658
|
+
const participants = participantsByBookingId.get(booking.id) ?? [];
|
|
1659
|
+
const items = itemsByBookingId.get(booking.id) ?? [];
|
|
1660
|
+
const bookingInvoices = invoicesByBookingId.get(booking.id) ?? [];
|
|
1661
|
+
const primaryTraveler = participants.find((participant) => participant.isPrimary) ?? participants[0] ?? null;
|
|
1662
|
+
return {
|
|
1663
|
+
bookingId: booking.id,
|
|
1664
|
+
bookingNumber: booking.bookingNumber,
|
|
1665
|
+
status: booking.status,
|
|
1666
|
+
sellCurrency: booking.sellCurrency,
|
|
1667
|
+
sellAmountCents: booking.sellAmountCents ?? null,
|
|
1668
|
+
productTitle: selectBookingSummaryProductTitle(items),
|
|
1669
|
+
paymentStatus: deriveBookingSummaryPaymentStatus(bookingInvoices, booking.sellAmountCents ?? null),
|
|
1670
|
+
startDate: normalizeDate(booking.startDate),
|
|
1671
|
+
endDate: normalizeDate(booking.endDate),
|
|
1672
|
+
pax: booking.pax ?? null,
|
|
1673
|
+
confirmedAt: normalizeDateTime(booking.confirmedAt),
|
|
1674
|
+
completedAt: normalizeDateTime(booking.completedAt),
|
|
1675
|
+
travelerCount: participants.length,
|
|
1676
|
+
primaryTravelerName: primaryTraveler
|
|
1677
|
+
? `${primaryTraveler.firstName} ${primaryTraveler.lastName}`.trim()
|
|
1678
|
+
: null,
|
|
1679
|
+
};
|
|
1680
|
+
});
|
|
1681
|
+
},
|
|
1682
|
+
async getBooking(db, userId, bookingId, options = {}) {
|
|
1683
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1684
|
+
if (!authProfile) {
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
const [linkedPersonId, customerRecord] = await Promise.all([
|
|
1688
|
+
resolveLinkedCustomerRecordId(db, userId),
|
|
1689
|
+
getCustomerRecord(db, userId),
|
|
1690
|
+
]);
|
|
1691
|
+
const authEmail = authProfile.email?.trim().toLowerCase() ?? null;
|
|
1692
|
+
const canAccess = await hasBookingAccess({
|
|
1693
|
+
db,
|
|
1694
|
+
bookingId,
|
|
1695
|
+
userId,
|
|
1696
|
+
authEmail,
|
|
1697
|
+
linkedPersonId,
|
|
1698
|
+
});
|
|
1699
|
+
if (!canAccess) {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
return buildBookingDetail(db, bookingId, customerRecord, options);
|
|
1703
|
+
},
|
|
1704
|
+
async listBookingDocuments(db, userId, bookingId, options = {}) {
|
|
1705
|
+
const detail = await this.getBooking(db, userId, bookingId, options);
|
|
1706
|
+
return detail?.documents ?? null;
|
|
1707
|
+
},
|
|
1708
|
+
async getBookingBillingContact(db, userId, bookingId) {
|
|
1709
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1710
|
+
if (!authProfile) {
|
|
1711
|
+
return null;
|
|
1712
|
+
}
|
|
1713
|
+
const [linkedPersonId, customerRecord] = await Promise.all([
|
|
1714
|
+
resolveLinkedCustomerRecordId(db, userId),
|
|
1715
|
+
getCustomerRecord(db, userId),
|
|
1716
|
+
]);
|
|
1717
|
+
const canAccess = await hasBookingAccess({
|
|
1718
|
+
db,
|
|
1719
|
+
bookingId,
|
|
1720
|
+
userId,
|
|
1721
|
+
authEmail: authProfile.email?.trim().toLowerCase() ?? null,
|
|
1722
|
+
linkedPersonId,
|
|
1723
|
+
});
|
|
1724
|
+
if (!canAccess) {
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
return getBookingBillingContact(db, bookingId, customerRecord);
|
|
1728
|
+
},
|
|
1729
|
+
// ── Identity documents ────────────────────────────────────────────────
|
|
1730
|
+
// CRUD over `crm.person_documents` scoped to the auth user's linked
|
|
1731
|
+
// person. Auto-creates the linked person row on first write so
|
|
1732
|
+
// phone-only / metadata-light customers can save documents without
|
|
1733
|
+
// a separate bootstrap step.
|
|
1734
|
+
async listMyDocuments(db, userId, options) {
|
|
1735
|
+
return getLinkedPersonDocuments(db, userId, options);
|
|
1736
|
+
},
|
|
1737
|
+
async createMyDocument(db, userId, input, options) {
|
|
1738
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
1739
|
+
if (!authProfile)
|
|
1740
|
+
return null;
|
|
1741
|
+
const personId = await ensureLinkedPerson(db, userId, authProfile);
|
|
1742
|
+
const numberEncrypted = await encryptDocumentNumber(input.number ?? null, options);
|
|
1743
|
+
const payload = {
|
|
1744
|
+
type: toCrmDocumentType(input.type),
|
|
1745
|
+
issuingAuthority: input.issuingAuthority ?? null,
|
|
1746
|
+
issuingCountry: input.issuingCountry ?? null,
|
|
1747
|
+
issueDate: input.issueDate ?? null,
|
|
1748
|
+
expiryDate: input.expiryDate ?? null,
|
|
1749
|
+
attachmentId: input.attachmentId ?? null,
|
|
1750
|
+
isPrimary: input.isPrimary ?? false,
|
|
1751
|
+
notes: input.notes ?? null,
|
|
1752
|
+
};
|
|
1753
|
+
if (numberEncrypted !== undefined) {
|
|
1754
|
+
payload.numberEncrypted = numberEncrypted;
|
|
1755
|
+
}
|
|
1756
|
+
const row = await relationshipsService.createPersonDocument(db, personId, payload);
|
|
1757
|
+
return row ? projectPersonDocumentToWire(row, options) : null;
|
|
1758
|
+
},
|
|
1759
|
+
async updateMyDocument(db, userId, documentId, input, options) {
|
|
1760
|
+
const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1761
|
+
if (!linkedPersonId)
|
|
1762
|
+
return null;
|
|
1763
|
+
const existing = await relationshipsService.getPersonDocument(db, documentId);
|
|
1764
|
+
if (!existing || existing.personId !== linkedPersonId)
|
|
1765
|
+
return null;
|
|
1766
|
+
const numberEncrypted = input.number !== undefined ? await encryptDocumentNumber(input.number, options) : undefined;
|
|
1767
|
+
const update = {};
|
|
1768
|
+
if (input.type !== undefined)
|
|
1769
|
+
update.type = toCrmDocumentType(input.type);
|
|
1770
|
+
if (input.issuingAuthority !== undefined)
|
|
1771
|
+
update.issuingAuthority = input.issuingAuthority;
|
|
1772
|
+
if (input.issuingCountry !== undefined)
|
|
1773
|
+
update.issuingCountry = input.issuingCountry;
|
|
1774
|
+
if (input.issueDate !== undefined)
|
|
1775
|
+
update.issueDate = input.issueDate;
|
|
1776
|
+
if (input.expiryDate !== undefined)
|
|
1777
|
+
update.expiryDate = input.expiryDate;
|
|
1778
|
+
if (input.attachmentId !== undefined)
|
|
1779
|
+
update.attachmentId = input.attachmentId;
|
|
1780
|
+
if (input.isPrimary !== undefined)
|
|
1781
|
+
update.isPrimary = input.isPrimary;
|
|
1782
|
+
if (input.notes !== undefined)
|
|
1783
|
+
update.notes = input.notes;
|
|
1784
|
+
if (numberEncrypted !== undefined)
|
|
1785
|
+
update.numberEncrypted = numberEncrypted;
|
|
1786
|
+
const row = await relationshipsService.updatePersonDocument(db, documentId, update);
|
|
1787
|
+
return row ? projectPersonDocumentToWire(row, options) : null;
|
|
1788
|
+
},
|
|
1789
|
+
async deleteMyDocument(db, userId, documentId) {
|
|
1790
|
+
const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1791
|
+
if (!linkedPersonId)
|
|
1792
|
+
return null;
|
|
1793
|
+
const existing = await relationshipsService.getPersonDocument(db, documentId);
|
|
1794
|
+
if (!existing || existing.personId !== linkedPersonId)
|
|
1795
|
+
return null;
|
|
1796
|
+
return relationshipsService.deletePersonDocument(db, documentId);
|
|
1797
|
+
},
|
|
1798
|
+
async setPrimaryMyDocument(db, userId, documentId, options) {
|
|
1799
|
+
const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
|
|
1800
|
+
if (!linkedPersonId)
|
|
1801
|
+
return null;
|
|
1802
|
+
const existing = await relationshipsService.getPersonDocument(db, documentId);
|
|
1803
|
+
if (!existing || existing.personId !== linkedPersonId)
|
|
1804
|
+
return null;
|
|
1805
|
+
const row = await relationshipsService.setPrimaryPersonDocument(db, documentId);
|
|
1806
|
+
return row ? projectPersonDocumentToWire(row, options) : null;
|
|
1807
|
+
},
|
|
1808
|
+
};
|