Backoffice contact details, currency mgmt. updates

This commit is contained in:
Stephanos A
2026-07-11 12:16:05 +03:00
parent 3c3924b33f
commit 50ebe8ddda
14 changed files with 323 additions and 355 deletions

View File

@@ -425,14 +425,47 @@ export class BookingsService {
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
).catch(() => [] as { id: string; email: string; name: any; phone_number: string | null }[])
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
// For bookings that have no contactEmail/contactPhone and no IAM match,
// fall back to TravelerProfile.notes JSON.
// Covers: (1) legacy authenticated bookings where IAM returns nothing,
// (2) guest bookings where passenger.iamUserId is null.
const passengerIdsNeedingFallback = regularItems
.filter((b: any) => !b.contactEmail && !b.contactPhone && (!b.passenger?.iamUserId || !iamMap.has(b.passenger.iamUserId)))
.map((b: any) => b.passengerId)
.filter(Boolean) as string[];
const travelerProfileMap = new Map<string, { phone: string | null; email: string | null }>();
if (passengerIdsNeedingFallback.length > 0) {
const profiles = await this.prisma.travelerProfile.findMany({
where: { passengerId: { in: passengerIdsNeedingFallback } },
select: { passengerId: true, notes: true },
orderBy: { createdAt: 'asc' },
});
for (const profile of profiles) {
if (travelerProfileMap.has(profile.passengerId)) continue;
try {
const notes = profile.notes ? (typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes) : null;
if (notes?.phone || notes?.email) {
travelerProfileMap.set(profile.passengerId, { phone: notes.phone ?? null, email: notes.email ?? null });
}
} catch { /* ignore */ }
}
}
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
const fallback = booking.passengerId ? travelerProfileMap.get(booking.passengerId) : null;
const resolvedEmail = booking.contactEmail ?? iam?.email ?? fallback?.email ?? null;
const resolvedPhone = booking.contactPhone ?? iam?.phone_number ?? fallback?.phone ?? null;
return {
id: booking.id,
bookingRef: booking.bookingRef,
@@ -441,8 +474,8 @@ export class BookingsService {
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
contactEmail: resolvedEmail,
contactPhone: resolvedPhone,
bookingType: booking.bookingType,
packageId: booking.packageId ?? null,
priceTierId: (booking as any).priceTierId ?? null,
@@ -530,6 +563,18 @@ export class BookingsService {
}
}
/** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */
private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
if (!passengerId) return { contactEmail: null, contactPhone: null };
const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, select: { iamUserId: true } });
if (!passenger?.iamUserId) return { contactEmail: null, contactPhone: null };
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[passenger.iamUserId],
);
return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null };
}
private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
@@ -547,7 +592,10 @@ export class BookingsService {
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const passengersData = await this.processPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = dto.packageId && dto.priceTierId
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
@@ -604,6 +652,8 @@ export class BookingsService {
childCount,
displayCurrency,
displayTotalMinor,
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: passengersWithFares.map(p => ({
@@ -675,7 +725,10 @@ export class BookingsService {
throw new NotFoundException('Origin or destination stops not found');
}
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
// Package bookings use fixed tier price split equally across both legs
@@ -782,6 +835,8 @@ export class BookingsService {
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: [
@@ -891,7 +946,10 @@ export class BookingsService {
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
const passengersData = await this.processPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
@@ -966,6 +1024,8 @@ export class BookingsService {
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId,
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
seats: {
create: [
...passengersWithFares.map(p => ({
@@ -1080,7 +1140,10 @@ export class BookingsService {
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const nat = passengersData[0]?.nationality;
@@ -1178,6 +1241,8 @@ export class BookingsService {
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
returnLeg2SeatClassId: retL2SeatClassId,
returnLegStatus: 'NEITHER_USED',
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
seats: {
create: [
// Outbound leg-1 (sequence 1)

View File

@@ -53,6 +53,23 @@ export class GuestBookingService {
) {}
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
// The portal calls /passengers/save-details before booking but doesn't re-send contact
// fields in the booking payload, so we pull them from the saved profile by deviceId.
if (dto.deviceId && dto.passengers?.length) {
const saved = await this.prisma.savedPassengerProfile.findMany({
where: { deviceId: dto.deviceId },
orderBy: { createdAt: 'desc' },
select: { passengerName: true, phone: true, email: true },
});
if (saved.length) {
dto.passengers = dto.passengers.map(p => {
if (p.phone && p.email) return p;
const match = saved.find(s => s.passengerName?.toLowerCase() === p.passengerName?.toLowerCase());
return { ...p, phone: p.phone || match?.phone || undefined, email: p.email || match?.email || undefined };
});
}
}
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);