@voyantjs/customer-portal 0.2.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 +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/routes-public.d.ts +568 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +128 -0
- package/dist/routes.d.ts +26 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +9 -0
- package/dist/service-public.d.ts +28 -0
- package/dist/service-public.d.ts.map +1 -0
- package/dist/service-public.js +715 -0
- package/dist/validation-public.d.ts +514 -0
- package/dist/validation-public.d.ts.map +1 -0
- package/dist/validation-public.js +277 -0
- package/package.json +67 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingParticipants, bookings, } from "@voyantjs/bookings/schema";
|
|
2
|
+
import { crmService, people } from "@voyantjs/crm";
|
|
3
|
+
import { authUser, userProfilesTable } from "@voyantjs/db/schema/iam";
|
|
4
|
+
import { identityContactPoints } from "@voyantjs/identity/schema";
|
|
5
|
+
import { identityService } from "@voyantjs/identity/service";
|
|
6
|
+
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
|
|
7
|
+
import { customerPortalBookingDetailSchema } from "./validation-public.js";
|
|
8
|
+
const linkedCustomerSource = "auth.user";
|
|
9
|
+
const companionMetadataKind = "companion";
|
|
10
|
+
function normalizeDate(value) {
|
|
11
|
+
if (!value) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (value instanceof Date) {
|
|
15
|
+
return value.toISOString().slice(0, 10);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function normalizeDateTime(value) {
|
|
20
|
+
if (!value) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
24
|
+
}
|
|
25
|
+
function normalizeNullableString(value) {
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = value?.trim();
|
|
30
|
+
return trimmed ? trimmed : null;
|
|
31
|
+
}
|
|
32
|
+
function normalizeEmail(value) {
|
|
33
|
+
return value.trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
function toCustomerCompanion(row) {
|
|
36
|
+
return {
|
|
37
|
+
id: row.id,
|
|
38
|
+
role: row.role,
|
|
39
|
+
name: row.name,
|
|
40
|
+
title: row.title ?? null,
|
|
41
|
+
email: row.email ?? null,
|
|
42
|
+
phone: row.phone ?? null,
|
|
43
|
+
isPrimary: row.isPrimary,
|
|
44
|
+
notes: row.notes ?? null,
|
|
45
|
+
metadata: row.metadata ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function getAuthProfileRow(db, userId) {
|
|
49
|
+
const [row] = await db
|
|
50
|
+
.select({
|
|
51
|
+
id: authUser.id,
|
|
52
|
+
email: authUser.email,
|
|
53
|
+
emailVerified: authUser.emailVerified,
|
|
54
|
+
name: authUser.name,
|
|
55
|
+
image: authUser.image,
|
|
56
|
+
firstName: userProfilesTable.firstName,
|
|
57
|
+
lastName: userProfilesTable.lastName,
|
|
58
|
+
avatarUrl: userProfilesTable.avatarUrl,
|
|
59
|
+
locale: userProfilesTable.locale,
|
|
60
|
+
timezone: userProfilesTable.timezone,
|
|
61
|
+
seatingPreference: userProfilesTable.seatingPreference,
|
|
62
|
+
marketingConsent: userProfilesTable.marketingConsent,
|
|
63
|
+
marketingConsentAt: userProfilesTable.marketingConsentAt,
|
|
64
|
+
notificationDefaults: userProfilesTable.notificationDefaults,
|
|
65
|
+
uiPrefs: userProfilesTable.uiPrefs,
|
|
66
|
+
})
|
|
67
|
+
.from(authUser)
|
|
68
|
+
.leftJoin(userProfilesTable, eq(userProfilesTable.id, authUser.id))
|
|
69
|
+
.where(eq(authUser.id, userId))
|
|
70
|
+
.limit(1);
|
|
71
|
+
return row ?? null;
|
|
72
|
+
}
|
|
73
|
+
async function resolveLinkedCustomerRecordId(db, userId) {
|
|
74
|
+
const [row] = await db
|
|
75
|
+
.select({ id: people.id })
|
|
76
|
+
.from(people)
|
|
77
|
+
.where(and(eq(people.source, linkedCustomerSource), eq(people.sourceRef, userId)))
|
|
78
|
+
.limit(1);
|
|
79
|
+
return row?.id ?? null;
|
|
80
|
+
}
|
|
81
|
+
async function listCustomerRecordCandidatesByEmail(db, email) {
|
|
82
|
+
const normalizedEmail = normalizeEmail(email);
|
|
83
|
+
const rows = await db
|
|
84
|
+
.select({
|
|
85
|
+
id: people.id,
|
|
86
|
+
firstName: people.firstName,
|
|
87
|
+
lastName: people.lastName,
|
|
88
|
+
preferredLanguage: people.preferredLanguage,
|
|
89
|
+
preferredCurrency: people.preferredCurrency,
|
|
90
|
+
birthday: people.birthday,
|
|
91
|
+
relation: people.relation,
|
|
92
|
+
status: people.status,
|
|
93
|
+
source: people.source,
|
|
94
|
+
sourceRef: people.sourceRef,
|
|
95
|
+
})
|
|
96
|
+
.from(people)
|
|
97
|
+
.innerJoin(identityContactPoints, and(eq(identityContactPoints.entityType, "person"), eq(identityContactPoints.entityId, people.id), eq(identityContactPoints.kind, "email"), eq(identityContactPoints.normalizedValue, normalizedEmail)))
|
|
98
|
+
.orderBy(desc(people.updatedAt));
|
|
99
|
+
const uniqueRows = new Map();
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
if (!uniqueRows.has(row.id)) {
|
|
102
|
+
uniqueRows.set(row.id, row);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const candidates = Array.from(uniqueRows.values()).map((row) => ({
|
|
106
|
+
id: row.id,
|
|
107
|
+
firstName: row.firstName,
|
|
108
|
+
lastName: row.lastName,
|
|
109
|
+
preferredLanguage: row.preferredLanguage ?? null,
|
|
110
|
+
preferredCurrency: row.preferredCurrency ?? null,
|
|
111
|
+
birthday: row.birthday ?? null,
|
|
112
|
+
email: normalizedEmail,
|
|
113
|
+
phone: null,
|
|
114
|
+
address: null,
|
|
115
|
+
city: null,
|
|
116
|
+
country: null,
|
|
117
|
+
relation: row.relation ?? null,
|
|
118
|
+
status: row.status,
|
|
119
|
+
claimedByAnotherUser: row.source === linkedCustomerSource && Boolean(row.sourceRef),
|
|
120
|
+
linkable: row.source === linkedCustomerSource ? row.sourceRef == null : row.sourceRef == null,
|
|
121
|
+
}));
|
|
122
|
+
return candidates;
|
|
123
|
+
}
|
|
124
|
+
async function getCustomerRecord(db, userId) {
|
|
125
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
126
|
+
if (!personId) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const person = await crmService.getPersonById(db, personId);
|
|
130
|
+
if (!person) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
id: person.id,
|
|
135
|
+
firstName: person.firstName,
|
|
136
|
+
lastName: person.lastName,
|
|
137
|
+
preferredLanguage: person.preferredLanguage ?? null,
|
|
138
|
+
preferredCurrency: person.preferredCurrency ?? null,
|
|
139
|
+
birthday: person.birthday ?? null,
|
|
140
|
+
email: person.email ?? null,
|
|
141
|
+
phone: person.phone ?? null,
|
|
142
|
+
address: person.address ?? null,
|
|
143
|
+
city: person.city ?? null,
|
|
144
|
+
country: person.country ?? null,
|
|
145
|
+
relation: person.relation ?? null,
|
|
146
|
+
status: person.status,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async function getAccessibleBookingIds(db, params) {
|
|
150
|
+
const linkedPersonId = await resolveLinkedCustomerRecordId(db, params.userId);
|
|
151
|
+
const email = params.email.trim().toLowerCase();
|
|
152
|
+
const [directBookingRows, participantPersonRows, participantEmailRows] = await Promise.all([
|
|
153
|
+
linkedPersonId
|
|
154
|
+
? db
|
|
155
|
+
.select({ bookingId: bookings.id })
|
|
156
|
+
.from(bookings)
|
|
157
|
+
.where(eq(bookings.personId, linkedPersonId))
|
|
158
|
+
: Promise.resolve([]),
|
|
159
|
+
linkedPersonId
|
|
160
|
+
? db
|
|
161
|
+
.select({ bookingId: bookingParticipants.bookingId })
|
|
162
|
+
.from(bookingParticipants)
|
|
163
|
+
.where(eq(bookingParticipants.personId, linkedPersonId))
|
|
164
|
+
: Promise.resolve([]),
|
|
165
|
+
db
|
|
166
|
+
.select({ bookingId: bookingParticipants.bookingId })
|
|
167
|
+
.from(bookingParticipants)
|
|
168
|
+
.where(sql `lower(${bookingParticipants.email}) = ${email}`),
|
|
169
|
+
]);
|
|
170
|
+
return Array.from(new Set([...directBookingRows, ...participantPersonRows, ...participantEmailRows].map((row) => row.bookingId)));
|
|
171
|
+
}
|
|
172
|
+
async function buildBookingDetail(db, bookingId) {
|
|
173
|
+
const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
|
|
174
|
+
if (!booking) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const [participants, items, itemParticipantLinks, documents, fulfillments] = await Promise.all([
|
|
178
|
+
db
|
|
179
|
+
.select()
|
|
180
|
+
.from(bookingParticipants)
|
|
181
|
+
.where(eq(bookingParticipants.bookingId, booking.id))
|
|
182
|
+
.orderBy(asc(bookingParticipants.createdAt)),
|
|
183
|
+
db
|
|
184
|
+
.select()
|
|
185
|
+
.from(bookingItems)
|
|
186
|
+
.where(eq(bookingItems.bookingId, booking.id))
|
|
187
|
+
.orderBy(asc(bookingItems.createdAt)),
|
|
188
|
+
db
|
|
189
|
+
.select({
|
|
190
|
+
id: bookingItemParticipants.id,
|
|
191
|
+
bookingItemId: bookingItemParticipants.bookingItemId,
|
|
192
|
+
participantId: bookingItemParticipants.participantId,
|
|
193
|
+
role: bookingItemParticipants.role,
|
|
194
|
+
isPrimary: bookingItemParticipants.isPrimary,
|
|
195
|
+
})
|
|
196
|
+
.from(bookingItemParticipants)
|
|
197
|
+
.innerJoin(bookingItems, eq(bookingItems.id, bookingItemParticipants.bookingItemId))
|
|
198
|
+
.where(eq(bookingItems.bookingId, booking.id))
|
|
199
|
+
.orderBy(asc(bookingItemParticipants.createdAt)),
|
|
200
|
+
db
|
|
201
|
+
.select()
|
|
202
|
+
.from(bookingDocuments)
|
|
203
|
+
.where(eq(bookingDocuments.bookingId, booking.id))
|
|
204
|
+
.orderBy(asc(bookingDocuments.createdAt)),
|
|
205
|
+
db
|
|
206
|
+
.select()
|
|
207
|
+
.from(bookingFulfillments)
|
|
208
|
+
.where(eq(bookingFulfillments.bookingId, booking.id))
|
|
209
|
+
.orderBy(asc(bookingFulfillments.createdAt)),
|
|
210
|
+
]);
|
|
211
|
+
const itemLinksByItemId = new Map();
|
|
212
|
+
for (const link of itemParticipantLinks) {
|
|
213
|
+
const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
|
|
214
|
+
existing.push({
|
|
215
|
+
id: link.id,
|
|
216
|
+
participantId: link.participantId,
|
|
217
|
+
role: link.role,
|
|
218
|
+
isPrimary: link.isPrimary,
|
|
219
|
+
});
|
|
220
|
+
itemLinksByItemId.set(link.bookingItemId, existing);
|
|
221
|
+
}
|
|
222
|
+
return customerPortalBookingDetailSchema.parse({
|
|
223
|
+
bookingId: booking.id,
|
|
224
|
+
bookingNumber: booking.bookingNumber,
|
|
225
|
+
status: booking.status,
|
|
226
|
+
sellCurrency: booking.sellCurrency,
|
|
227
|
+
sellAmountCents: booking.sellAmountCents ?? null,
|
|
228
|
+
startDate: normalizeDate(booking.startDate),
|
|
229
|
+
endDate: normalizeDate(booking.endDate),
|
|
230
|
+
pax: booking.pax ?? null,
|
|
231
|
+
confirmedAt: normalizeDateTime(booking.confirmedAt),
|
|
232
|
+
cancelledAt: normalizeDateTime(booking.cancelledAt),
|
|
233
|
+
completedAt: normalizeDateTime(booking.completedAt),
|
|
234
|
+
participants: participants.map((participant) => ({
|
|
235
|
+
id: participant.id,
|
|
236
|
+
participantType: participant.participantType,
|
|
237
|
+
firstName: participant.firstName,
|
|
238
|
+
lastName: participant.lastName,
|
|
239
|
+
isPrimary: participant.isPrimary,
|
|
240
|
+
})),
|
|
241
|
+
items: items.map((item) => ({
|
|
242
|
+
id: item.id,
|
|
243
|
+
title: item.title,
|
|
244
|
+
description: item.description ?? null,
|
|
245
|
+
itemType: item.itemType,
|
|
246
|
+
status: item.status,
|
|
247
|
+
serviceDate: normalizeDate(item.serviceDate),
|
|
248
|
+
startsAt: normalizeDateTime(item.startsAt),
|
|
249
|
+
endsAt: normalizeDateTime(item.endsAt),
|
|
250
|
+
quantity: item.quantity,
|
|
251
|
+
sellCurrency: item.sellCurrency,
|
|
252
|
+
unitSellAmountCents: item.unitSellAmountCents ?? null,
|
|
253
|
+
totalSellAmountCents: item.totalSellAmountCents ?? null,
|
|
254
|
+
notes: item.notes ?? null,
|
|
255
|
+
participantLinks: itemLinksByItemId.get(item.id) ?? [],
|
|
256
|
+
})),
|
|
257
|
+
documents: documents.map((document) => ({
|
|
258
|
+
id: document.id,
|
|
259
|
+
participantId: document.participantId ?? null,
|
|
260
|
+
type: document.type,
|
|
261
|
+
fileName: document.fileName,
|
|
262
|
+
fileUrl: document.fileUrl,
|
|
263
|
+
})),
|
|
264
|
+
fulfillments: fulfillments.map((fulfillment) => ({
|
|
265
|
+
id: fulfillment.id,
|
|
266
|
+
bookingItemId: fulfillment.bookingItemId ?? null,
|
|
267
|
+
participantId: fulfillment.participantId ?? null,
|
|
268
|
+
fulfillmentType: fulfillment.fulfillmentType,
|
|
269
|
+
deliveryChannel: fulfillment.deliveryChannel,
|
|
270
|
+
status: fulfillment.status,
|
|
271
|
+
artifactUrl: fulfillment.artifactUrl ?? null,
|
|
272
|
+
})),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
export const publicCustomerPortalService = {
|
|
276
|
+
async contactExists(db, email) {
|
|
277
|
+
const normalizedEmail = normalizeEmail(email);
|
|
278
|
+
const [authAccount, customerCandidates] = await Promise.all([
|
|
279
|
+
db
|
|
280
|
+
.select({ id: authUser.id })
|
|
281
|
+
.from(authUser)
|
|
282
|
+
.where(sql `lower(${authUser.email}) = ${normalizedEmail}`)
|
|
283
|
+
.limit(1),
|
|
284
|
+
listCustomerRecordCandidatesByEmail(db, normalizedEmail),
|
|
285
|
+
]);
|
|
286
|
+
return {
|
|
287
|
+
email: normalizedEmail,
|
|
288
|
+
authAccountExists: Boolean(authAccount[0]),
|
|
289
|
+
customerRecordExists: customerCandidates.length > 0,
|
|
290
|
+
linkedCustomerRecordExists: customerCandidates.some((candidate) => candidate.claimedByAnotherUser),
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
async getProfile(db, userId) {
|
|
294
|
+
const [authProfile, customerRecord] = await Promise.all([
|
|
295
|
+
getAuthProfileRow(db, userId),
|
|
296
|
+
getCustomerRecord(db, userId),
|
|
297
|
+
]);
|
|
298
|
+
if (!authProfile) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
userId: authProfile.id,
|
|
303
|
+
email: authProfile.email,
|
|
304
|
+
emailVerified: authProfile.emailVerified,
|
|
305
|
+
firstName: authProfile.firstName ?? null,
|
|
306
|
+
lastName: authProfile.lastName ?? null,
|
|
307
|
+
avatarUrl: authProfile.avatarUrl ?? authProfile.image ?? null,
|
|
308
|
+
locale: authProfile.locale ?? "en",
|
|
309
|
+
timezone: authProfile.timezone ?? null,
|
|
310
|
+
seatingPreference: authProfile.seatingPreference ?? null,
|
|
311
|
+
marketingConsent: authProfile.marketingConsent ?? false,
|
|
312
|
+
marketingConsentAt: normalizeDateTime(authProfile.marketingConsentAt),
|
|
313
|
+
notificationDefaults: authProfile.notificationDefaults ?? null,
|
|
314
|
+
uiPrefs: authProfile.uiPrefs ?? null,
|
|
315
|
+
customerRecord,
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
async updateProfile(db, userId, input) {
|
|
319
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
320
|
+
if (!authProfile) {
|
|
321
|
+
return { error: "not_found" };
|
|
322
|
+
}
|
|
323
|
+
const customerRecordId = await resolveLinkedCustomerRecordId(db, userId);
|
|
324
|
+
if (input.customerRecord && !customerRecordId) {
|
|
325
|
+
return { error: "customer_record_required" };
|
|
326
|
+
}
|
|
327
|
+
const nextFirstName = input.firstName ?? authProfile.firstName ?? null;
|
|
328
|
+
const nextLastName = input.lastName ?? authProfile.lastName ?? null;
|
|
329
|
+
const nextDisplayName = [nextFirstName, nextLastName].filter(Boolean).join(" ").trim();
|
|
330
|
+
await db
|
|
331
|
+
.insert(userProfilesTable)
|
|
332
|
+
.values({
|
|
333
|
+
id: userId,
|
|
334
|
+
firstName: nextFirstName,
|
|
335
|
+
lastName: nextLastName,
|
|
336
|
+
avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
|
|
337
|
+
locale: input.locale ?? authProfile.locale ?? "en",
|
|
338
|
+
timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
|
|
339
|
+
seatingPreference: input.seatingPreference !== undefined
|
|
340
|
+
? input.seatingPreference
|
|
341
|
+
: (authProfile.seatingPreference ?? null),
|
|
342
|
+
marketingConsent: input.marketingConsent !== undefined
|
|
343
|
+
? input.marketingConsent
|
|
344
|
+
: (authProfile.marketingConsent ?? false),
|
|
345
|
+
marketingConsentAt: input.marketingConsent === undefined
|
|
346
|
+
? (authProfile.marketingConsentAt ?? null)
|
|
347
|
+
: input.marketingConsent
|
|
348
|
+
? authProfile.marketingConsent
|
|
349
|
+
? (authProfile.marketingConsentAt ?? new Date())
|
|
350
|
+
: new Date()
|
|
351
|
+
: null,
|
|
352
|
+
notificationDefaults: input.notificationDefaults !== undefined
|
|
353
|
+
? input.notificationDefaults
|
|
354
|
+
: (authProfile.notificationDefaults ?? {}),
|
|
355
|
+
uiPrefs: input.uiPrefs !== undefined
|
|
356
|
+
? input.uiPrefs
|
|
357
|
+
: (authProfile.uiPrefs ?? {}),
|
|
358
|
+
})
|
|
359
|
+
.onConflictDoUpdate({
|
|
360
|
+
target: userProfilesTable.id,
|
|
361
|
+
set: {
|
|
362
|
+
firstName: nextFirstName,
|
|
363
|
+
lastName: nextLastName,
|
|
364
|
+
avatarUrl: input.avatarUrl ?? authProfile.avatarUrl ?? authProfile.image ?? null,
|
|
365
|
+
locale: input.locale ?? authProfile.locale ?? "en",
|
|
366
|
+
timezone: input.timezone !== undefined ? input.timezone : (authProfile.timezone ?? null),
|
|
367
|
+
seatingPreference: input.seatingPreference !== undefined
|
|
368
|
+
? input.seatingPreference
|
|
369
|
+
: (authProfile.seatingPreference ?? null),
|
|
370
|
+
marketingConsent: input.marketingConsent !== undefined
|
|
371
|
+
? input.marketingConsent
|
|
372
|
+
: (authProfile.marketingConsent ?? false),
|
|
373
|
+
marketingConsentAt: input.marketingConsent === undefined
|
|
374
|
+
? (authProfile.marketingConsentAt ?? null)
|
|
375
|
+
: input.marketingConsent
|
|
376
|
+
? authProfile.marketingConsent
|
|
377
|
+
? (authProfile.marketingConsentAt ?? new Date())
|
|
378
|
+
: new Date()
|
|
379
|
+
: null,
|
|
380
|
+
notificationDefaults: input.notificationDefaults !== undefined
|
|
381
|
+
? input.notificationDefaults
|
|
382
|
+
: (authProfile.notificationDefaults ?? {}),
|
|
383
|
+
uiPrefs: input.uiPrefs !== undefined
|
|
384
|
+
? input.uiPrefs
|
|
385
|
+
: (authProfile.uiPrefs ?? {}),
|
|
386
|
+
updatedAt: new Date(),
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
await db
|
|
390
|
+
.update(authUser)
|
|
391
|
+
.set({
|
|
392
|
+
name: nextDisplayName || authProfile.name,
|
|
393
|
+
image: input.avatarUrl !== undefined ? input.avatarUrl : (authProfile.image ?? null),
|
|
394
|
+
updatedAt: new Date(),
|
|
395
|
+
})
|
|
396
|
+
.where(eq(authUser.id, userId));
|
|
397
|
+
if (customerRecordId) {
|
|
398
|
+
const nextCustomerRecord = input.customerRecord;
|
|
399
|
+
if (nextCustomerRecord || input.firstName !== undefined || input.lastName !== undefined) {
|
|
400
|
+
await crmService.updatePerson(db, customerRecordId, {
|
|
401
|
+
...(input.firstName !== undefined ? { firstName: input.firstName ?? "" } : {}),
|
|
402
|
+
...(input.lastName !== undefined ? { lastName: input.lastName ?? "" } : {}),
|
|
403
|
+
...(nextCustomerRecord?.preferredLanguage !== undefined
|
|
404
|
+
? { preferredLanguage: nextCustomerRecord.preferredLanguage }
|
|
405
|
+
: {}),
|
|
406
|
+
...(nextCustomerRecord?.preferredCurrency !== undefined
|
|
407
|
+
? { preferredCurrency: nextCustomerRecord.preferredCurrency }
|
|
408
|
+
: {}),
|
|
409
|
+
...(nextCustomerRecord?.birthday !== undefined
|
|
410
|
+
? { birthday: nextCustomerRecord.birthday }
|
|
411
|
+
: {}),
|
|
412
|
+
...(nextCustomerRecord?.phone !== undefined ? { phone: nextCustomerRecord.phone } : {}),
|
|
413
|
+
...(nextCustomerRecord?.address !== undefined
|
|
414
|
+
? { address: nextCustomerRecord.address }
|
|
415
|
+
: {}),
|
|
416
|
+
...(nextCustomerRecord?.city !== undefined ? { city: nextCustomerRecord.city } : {}),
|
|
417
|
+
...(nextCustomerRecord?.country !== undefined
|
|
418
|
+
? { country: nextCustomerRecord.country }
|
|
419
|
+
: {}),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const profile = await this.getProfile(db, userId);
|
|
424
|
+
if (!profile) {
|
|
425
|
+
return { error: "not_found" };
|
|
426
|
+
}
|
|
427
|
+
return { profile };
|
|
428
|
+
},
|
|
429
|
+
async bootstrap(db, userId, input) {
|
|
430
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
431
|
+
if (!authProfile) {
|
|
432
|
+
return { error: "not_found" };
|
|
433
|
+
}
|
|
434
|
+
const linkedCustomerRecordId = await resolveLinkedCustomerRecordId(db, userId);
|
|
435
|
+
if (linkedCustomerRecordId) {
|
|
436
|
+
const profile = await this.getProfile(db, userId);
|
|
437
|
+
return {
|
|
438
|
+
status: "already_linked",
|
|
439
|
+
profile,
|
|
440
|
+
candidates: [],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const normalizedEmail = normalizeEmail(authProfile.email);
|
|
444
|
+
const nextFirstName = input.firstName ?? authProfile.firstName ?? authProfile.name.split(" ")[0] ?? "Customer";
|
|
445
|
+
const nextLastName = input.lastName ?? authProfile.lastName ?? authProfile.name.split(" ").slice(1).join(" ") ?? "";
|
|
446
|
+
if (input.marketingConsent !== undefined) {
|
|
447
|
+
await db
|
|
448
|
+
.insert(userProfilesTable)
|
|
449
|
+
.values({
|
|
450
|
+
id: userId,
|
|
451
|
+
marketingConsent: input.marketingConsent,
|
|
452
|
+
marketingConsentAt: input.marketingConsent ? new Date() : null,
|
|
453
|
+
})
|
|
454
|
+
.onConflictDoUpdate({
|
|
455
|
+
target: userProfilesTable.id,
|
|
456
|
+
set: {
|
|
457
|
+
marketingConsent: input.marketingConsent,
|
|
458
|
+
marketingConsentAt: input.marketingConsent ? new Date() : null,
|
|
459
|
+
updatedAt: new Date(),
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (input.customerRecordId) {
|
|
464
|
+
const person = await crmService.getPersonById(db, input.customerRecordId);
|
|
465
|
+
if (!person) {
|
|
466
|
+
return { error: "customer_record_not_found" };
|
|
467
|
+
}
|
|
468
|
+
if (person.source === linkedCustomerSource &&
|
|
469
|
+
person.sourceRef &&
|
|
470
|
+
person.sourceRef !== userId) {
|
|
471
|
+
return { error: "customer_record_claimed" };
|
|
472
|
+
}
|
|
473
|
+
const updated = await crmService.updatePerson(db, input.customerRecordId, {
|
|
474
|
+
source: linkedCustomerSource,
|
|
475
|
+
sourceRef: userId,
|
|
476
|
+
...(input.firstName !== undefined ? { firstName: nextFirstName } : {}),
|
|
477
|
+
...(input.lastName !== undefined ? { lastName: nextLastName } : {}),
|
|
478
|
+
...(input.customerRecord?.preferredLanguage !== undefined
|
|
479
|
+
? { preferredLanguage: input.customerRecord.preferredLanguage }
|
|
480
|
+
: {}),
|
|
481
|
+
...(input.customerRecord?.preferredCurrency !== undefined
|
|
482
|
+
? { preferredCurrency: input.customerRecord.preferredCurrency }
|
|
483
|
+
: {}),
|
|
484
|
+
...(input.customerRecord?.birthday !== undefined
|
|
485
|
+
? { birthday: input.customerRecord.birthday }
|
|
486
|
+
: {}),
|
|
487
|
+
...(input.customerRecord?.phone !== undefined ? { phone: input.customerRecord.phone } : {}),
|
|
488
|
+
...(input.customerRecord?.address !== undefined
|
|
489
|
+
? { address: input.customerRecord.address }
|
|
490
|
+
: {}),
|
|
491
|
+
...(input.customerRecord?.city !== undefined ? { city: input.customerRecord.city } : {}),
|
|
492
|
+
...(input.customerRecord?.country !== undefined
|
|
493
|
+
? { country: input.customerRecord.country }
|
|
494
|
+
: {}),
|
|
495
|
+
});
|
|
496
|
+
if (!updated) {
|
|
497
|
+
return { error: "customer_record_not_found" };
|
|
498
|
+
}
|
|
499
|
+
const profile = await this.getProfile(db, userId);
|
|
500
|
+
return {
|
|
501
|
+
status: "linked_existing_customer",
|
|
502
|
+
profile,
|
|
503
|
+
candidates: [],
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const customerCandidates = await listCustomerRecordCandidatesByEmail(db, normalizedEmail);
|
|
507
|
+
const selectableCandidates = customerCandidates.filter((candidate) => !candidate.claimedByAnotherUser);
|
|
508
|
+
if (selectableCandidates.length > 0) {
|
|
509
|
+
return {
|
|
510
|
+
status: "customer_selection_required",
|
|
511
|
+
profile: null,
|
|
512
|
+
candidates: selectableCandidates,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (!input.createCustomerIfMissing) {
|
|
516
|
+
return {
|
|
517
|
+
status: "customer_selection_required",
|
|
518
|
+
profile: null,
|
|
519
|
+
candidates: [],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const created = await crmService.createPerson(db, {
|
|
523
|
+
firstName: nextFirstName,
|
|
524
|
+
lastName: nextLastName || "Customer",
|
|
525
|
+
preferredLanguage: input.customerRecord?.preferredLanguage ?? authProfile.locale ?? null,
|
|
526
|
+
preferredCurrency: input.customerRecord?.preferredCurrency ?? null,
|
|
527
|
+
birthday: input.customerRecord?.birthday ?? null,
|
|
528
|
+
relation: "client",
|
|
529
|
+
status: "active",
|
|
530
|
+
source: linkedCustomerSource,
|
|
531
|
+
sourceRef: userId,
|
|
532
|
+
tags: [],
|
|
533
|
+
email: normalizedEmail,
|
|
534
|
+
phone: input.customerRecord?.phone ?? null,
|
|
535
|
+
website: null,
|
|
536
|
+
address: input.customerRecord?.address ?? null,
|
|
537
|
+
city: input.customerRecord?.city ?? null,
|
|
538
|
+
country: input.customerRecord?.country ?? null,
|
|
539
|
+
});
|
|
540
|
+
if (!created) {
|
|
541
|
+
return { error: "not_found" };
|
|
542
|
+
}
|
|
543
|
+
const profile = await this.getProfile(db, userId);
|
|
544
|
+
return {
|
|
545
|
+
status: "created_customer",
|
|
546
|
+
profile,
|
|
547
|
+
candidates: [],
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
async listCompanions(db, userId) {
|
|
551
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
552
|
+
if (!personId) {
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
const rows = await identityService.listNamedContactsForEntity(db, "person", personId);
|
|
556
|
+
return rows
|
|
557
|
+
.filter((row) => (row.metadata?.kind ?? null) ===
|
|
558
|
+
companionMetadataKind)
|
|
559
|
+
.map(toCustomerCompanion);
|
|
560
|
+
},
|
|
561
|
+
async createCompanion(db, userId, input) {
|
|
562
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
563
|
+
if (!personId) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
const row = await identityService.createNamedContact(db, {
|
|
567
|
+
entityType: "person",
|
|
568
|
+
entityId: personId,
|
|
569
|
+
role: input.role,
|
|
570
|
+
name: input.name,
|
|
571
|
+
title: input.title ?? null,
|
|
572
|
+
email: normalizeNullableString(input.email),
|
|
573
|
+
phone: normalizeNullableString(input.phone),
|
|
574
|
+
isPrimary: input.isPrimary,
|
|
575
|
+
notes: normalizeNullableString(input.notes),
|
|
576
|
+
metadata: {
|
|
577
|
+
kind: companionMetadataKind,
|
|
578
|
+
...(input.metadata ?? {}),
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
return row ? toCustomerCompanion(row) : null;
|
|
582
|
+
},
|
|
583
|
+
async updateCompanion(db, userId, companionId, input) {
|
|
584
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
585
|
+
if (!personId) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
const existing = await identityService.getNamedContactById(db, companionId);
|
|
589
|
+
if (!existing ||
|
|
590
|
+
existing.entityType !== "person" ||
|
|
591
|
+
existing.entityId !== personId ||
|
|
592
|
+
(existing.metadata?.kind ?? null) !==
|
|
593
|
+
companionMetadataKind) {
|
|
594
|
+
return "forbidden";
|
|
595
|
+
}
|
|
596
|
+
const row = await identityService.updateNamedContact(db, companionId, {
|
|
597
|
+
...(input.role !== undefined ? { role: input.role } : {}),
|
|
598
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
599
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
600
|
+
...(input.email !== undefined ? { email: normalizeNullableString(input.email) } : {}),
|
|
601
|
+
...(input.phone !== undefined ? { phone: normalizeNullableString(input.phone) } : {}),
|
|
602
|
+
...(input.isPrimary !== undefined ? { isPrimary: input.isPrimary } : {}),
|
|
603
|
+
...(input.notes !== undefined ? { notes: normalizeNullableString(input.notes) } : {}),
|
|
604
|
+
...(input.metadata !== undefined
|
|
605
|
+
? {
|
|
606
|
+
metadata: {
|
|
607
|
+
kind: companionMetadataKind,
|
|
608
|
+
...(input.metadata ?? {}),
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
: {}),
|
|
612
|
+
});
|
|
613
|
+
return row ? toCustomerCompanion(row) : null;
|
|
614
|
+
},
|
|
615
|
+
async deleteCompanion(db, userId, companionId) {
|
|
616
|
+
const personId = await resolveLinkedCustomerRecordId(db, userId);
|
|
617
|
+
if (!personId) {
|
|
618
|
+
return "not_found";
|
|
619
|
+
}
|
|
620
|
+
const existing = await identityService.getNamedContactById(db, companionId);
|
|
621
|
+
if (!existing) {
|
|
622
|
+
return "not_found";
|
|
623
|
+
}
|
|
624
|
+
if (existing.entityType !== "person" ||
|
|
625
|
+
existing.entityId !== personId ||
|
|
626
|
+
(existing.metadata?.kind ?? null) !==
|
|
627
|
+
companionMetadataKind) {
|
|
628
|
+
return "forbidden";
|
|
629
|
+
}
|
|
630
|
+
await identityService.deleteNamedContact(db, companionId);
|
|
631
|
+
return "deleted";
|
|
632
|
+
},
|
|
633
|
+
async listBookings(db, userId) {
|
|
634
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
635
|
+
if (!authProfile) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
const bookingIds = await getAccessibleBookingIds(db, { userId, email: authProfile.email });
|
|
639
|
+
if (bookingIds.length === 0) {
|
|
640
|
+
return [];
|
|
641
|
+
}
|
|
642
|
+
const [bookingRows, participantRows] = await Promise.all([
|
|
643
|
+
db
|
|
644
|
+
.select()
|
|
645
|
+
.from(bookings)
|
|
646
|
+
.where(inArray(bookings.id, bookingIds))
|
|
647
|
+
.orderBy(desc(bookings.createdAt)),
|
|
648
|
+
db
|
|
649
|
+
.select()
|
|
650
|
+
.from(bookingParticipants)
|
|
651
|
+
.where(inArray(bookingParticipants.bookingId, bookingIds))
|
|
652
|
+
.orderBy(asc(bookingParticipants.createdAt)),
|
|
653
|
+
]);
|
|
654
|
+
const participantsByBookingId = new Map();
|
|
655
|
+
for (const participant of participantRows) {
|
|
656
|
+
const bucket = participantsByBookingId.get(participant.bookingId) ?? [];
|
|
657
|
+
bucket.push(participant);
|
|
658
|
+
participantsByBookingId.set(participant.bookingId, bucket);
|
|
659
|
+
}
|
|
660
|
+
return bookingRows.map((booking) => {
|
|
661
|
+
const participants = participantsByBookingId.get(booking.id) ?? [];
|
|
662
|
+
const primaryTraveler = participants.find((participant) => participant.isPrimary) ?? participants[0] ?? null;
|
|
663
|
+
return {
|
|
664
|
+
bookingId: booking.id,
|
|
665
|
+
bookingNumber: booking.bookingNumber,
|
|
666
|
+
status: booking.status,
|
|
667
|
+
sellCurrency: booking.sellCurrency,
|
|
668
|
+
sellAmountCents: booking.sellAmountCents ?? null,
|
|
669
|
+
startDate: normalizeDate(booking.startDate),
|
|
670
|
+
endDate: normalizeDate(booking.endDate),
|
|
671
|
+
pax: booking.pax ?? null,
|
|
672
|
+
confirmedAt: normalizeDateTime(booking.confirmedAt),
|
|
673
|
+
completedAt: normalizeDateTime(booking.completedAt),
|
|
674
|
+
participantCount: participants.length,
|
|
675
|
+
primaryTravelerName: primaryTraveler
|
|
676
|
+
? `${primaryTraveler.firstName} ${primaryTraveler.lastName}`.trim()
|
|
677
|
+
: null,
|
|
678
|
+
};
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
async getBooking(db, userId, bookingId) {
|
|
682
|
+
const authProfile = await getAuthProfileRow(db, userId);
|
|
683
|
+
if (!authProfile) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
const linkedPersonId = await resolveLinkedCustomerRecordId(db, userId);
|
|
687
|
+
const authEmail = authProfile.email.trim().toLowerCase();
|
|
688
|
+
const ownershipConditions = [sql `lower(${bookingParticipants.email}) = ${authEmail}`];
|
|
689
|
+
if (linkedPersonId) {
|
|
690
|
+
ownershipConditions.push(eq(bookingParticipants.personId, linkedPersonId));
|
|
691
|
+
}
|
|
692
|
+
const [participantMatch, bookingMatch] = await Promise.all([
|
|
693
|
+
db
|
|
694
|
+
.select({ bookingId: bookingParticipants.bookingId })
|
|
695
|
+
.from(bookingParticipants)
|
|
696
|
+
.where(and(eq(bookingParticipants.bookingId, bookingId), or(...ownershipConditions)))
|
|
697
|
+
.limit(1),
|
|
698
|
+
linkedPersonId
|
|
699
|
+
? db
|
|
700
|
+
.select({ bookingId: bookings.id })
|
|
701
|
+
.from(bookings)
|
|
702
|
+
.where(and(eq(bookings.id, bookingId), eq(bookings.personId, linkedPersonId)))
|
|
703
|
+
.limit(1)
|
|
704
|
+
: Promise.resolve([]),
|
|
705
|
+
]);
|
|
706
|
+
if (!participantMatch[0] && !bookingMatch[0]) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
return buildBookingDetail(db, bookingId);
|
|
710
|
+
},
|
|
711
|
+
async listBookingDocuments(db, userId, bookingId) {
|
|
712
|
+
const detail = await this.getBooking(db, userId, bookingId);
|
|
713
|
+
return detail?.documents ?? null;
|
|
714
|
+
},
|
|
715
|
+
};
|