@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.
@@ -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
+ };