diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index d9c393ee7..c5c8a0b49 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -53,15 +53,17 @@ function normalizePhoneVariants(raw: string): string[] { const variants = new Set([stripped]); if (stripped.startsWith('+251') && digits.length === 12) { - // +251 9XXXXXXXX → 09XXXXXXXX - variants.add('0' + digits.slice(3)); + // +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX + variants.add(digits); // 251XXXXXXXXX + variants.add('0' + digits.slice(3)); // 09XXXXXXXXX } else if (stripped.startsWith('251') && digits.length === 12) { // 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX - variants.add('+' + stripped); - variants.add('0' + digits.slice(3)); + variants.add('+' + stripped); // +251XXXXXXXXX + variants.add('0' + digits.slice(3)); // 09XXXXXXXXX } else if (stripped.startsWith('0') && digits.length === 10) { - // 09XXXXXXXX → +251 9XXXXXXXX - variants.add('+251' + digits.slice(1)); + // 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +) + variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX + variants.add('251' + digits.slice(1)); // 251XXXXXXXXX } else if (!stripped.startsWith('+') && digits.length >= 9) { // bare international digits without + variants.add('+' + digits); @@ -183,15 +185,81 @@ export class BookingsService { const { status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; + // Authenticated-user bookings don't store contactPhone — their phone lives in + // iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup + // that findAll uses for the search field. + const iamRows = await this.dataSource + .query<{ id: string }[]>( + `SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`, + [variants], + ) + .catch((err: unknown) => { + this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { id: string }[]; + }); + + const iamPassengerIds = iamRows.length > 0 + ? (await this.prisma.passenger.findMany({ + where: { iamUserId: { in: iamRows.map(r => r.id) } }, + select: { id: true }, + })).map(p => p.id) + : []; + + // Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking). + // This catches cases where contactPhone was null but the phone was still recorded in the profile. + const travelerRows = await this.dataSource + .query<{ passengerId: string }[]>( + `SELECT DISTINCT passenger_id AS "passengerId" + FROM passenger.traveler_profiles + WHERE notes IS NOT NULL + AND (notes::jsonb->>'phone') = ANY($1::text[])`, + [variants], + ) + .catch((err: unknown) => { + this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { passengerId: string }[]; + }); + const travelerPassengerIds = travelerRows.map(r => r.passengerId); + + // Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile + // row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent. + const savedProfileRows = await this.dataSource + .query<{ deviceId: string }[]>( + `SELECT DISTINCT device_id AS "deviceId" + FROM passenger.saved_passenger_profiles + WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`, + [variants], + ) + .catch((err: unknown) => { + this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { deviceId: string }[]; + }); + const guestDeviceIds = savedProfileRows.map(r => r.deviceId); + + // Merge all passenger IDs from every source + const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])]; + const where: any = { OR: [ { contactPhone: { in: variants } }, { passenger: { user: { phone: { in: variants } } } }, + ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), + ...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []), ], }; if (status) where.status = status; - const [items, total] = await Promise.all([ + // PackageBooking is a separate table with its own contactPhone field — + // must be queried independently or guest package bookings are invisible. + const pkgWhere: any = { + OR: [ + { contactPhone: { in: variants } }, + ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), + ], + }; + if (status) pkgWhere.status = status; + + const [items, total, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, @@ -205,37 +273,84 @@ export class BookingsService { }, }), this.prisma.booking.count({ where }), + this.prisma.packageBooking.findMany({ + where: pkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + package: { + include: { + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }, + paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, + }, + }), + this.prisma.packageBooking.count({ where: pkgWhere }), ]); + const mappedBookings = items.map(booking => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + arrivalAt: booking.schedule.arrivalAt, + }, + payment: booking.paymentIntent ?? undefined, + seatCount: booking.seats.length, + })); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalMinor: b.totalMinor, + currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency ?? null, + displayTotalMinor: b.displayTotalMinor ?? null, + adultCount: b.adultCount, + childCount: b.childCount, + bookingType: 'PACKAGE', + returnLegStatus: null, + createdAt: b.createdAt, + schedule: b.package?.outboundSchedule + ? { + train: null, + originStation: b.package.outboundSchedule.originStation, + destinationStation: b.package.outboundSchedule.destinationStation, + departureAt: b.package.outboundSchedule.departureAt, + arrivalAt: b.package.outboundSchedule.arrivalAt, + } + : null, + payment: b.paymentIntent ?? undefined, + seatCount: b.passengerCount, + })); + + const allItems = [...mappedBookings, ...mappedPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - adultCount: booking.adultCount, - childCount: booking.childCount, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - createdAt: booking.createdAt, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, - }, - payment: booking.paymentIntent ?? undefined, - seatCount: booking.seats.length, - })), + items: allItems, meta: { page, pageSize, - total, - totalPages: Math.ceil(total / pageSize), + total: total + pkgTotal, + totalPages: Math.ceil((total + pkgTotal) / pageSize), }, }; } @@ -520,14 +635,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(); + 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, @@ -536,8 +684,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, @@ -625,6 +773,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'); @@ -642,7 +802,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) @@ -699,6 +862,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 => ({ @@ -770,7 +935,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 @@ -877,6 +1045,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: [ @@ -986,7 +1156,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; @@ -1061,6 +1234,8 @@ export class BookingsService { leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId, + contactEmail: iamContact.contactEmail, + contactPhone: iamContact.contactPhone, seats: { create: [ ...passengersWithFares.map(p => ({ @@ -1175,7 +1350,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; @@ -1273,6 +1451,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) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index b3e7fbf3e..080b4404f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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); diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 388ed2cda..8c875eb8b 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -105,6 +105,13 @@ export class CurrenciesService { }; } + async syncExchangeRates() { + // Placeholder: in production this would fetch from an external FX API. + // For now, return the current rates as-is. + const currencies = await this.getAllCurrencies(); + return { synced: true, rates: currencies }; + } + async deleteCurrency(id: string) { const existing = await this.prisma.currencyExchangeRate.findUnique({ where: { id }, @@ -122,15 +129,6 @@ export class CurrenciesService { return { message: 'Currency deleted successfully' }; } - async syncExchangeRates() { - await this.currencyService.syncExchangeRates(); - const rates = await this.prisma.currencyExchangeRate.findMany({ - orderBy: { effectiveDate: 'desc' }, - take: 10, - }); - return { message: 'Exchange rates synced successfully', synced: rates.length }; - } - private getCurrencyName(code: string): string { const names: Record = { ETB: 'Ethiopian Birr', diff --git a/apps/edr-passenger-api/src/modules/currency/currency.controller.ts b/apps/edr-passenger-api/src/modules/currency/currency.controller.ts new file mode 100644 index 000000000..79f8f2c3c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currency/currency.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, SetMetadata } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { Currency } from '@prisma/client'; +import { CurrencyService } from './currency.service'; +import { PassengerAdmin } from '../../common/passenger-guards'; + +class CreateRateDto { + @IsEnum(Currency) fromCurrency: Currency; + @IsEnum(Currency) toCurrency: Currency; + @IsNumber() @Min(0.000001) rate: number; + @IsOptional() @IsString() source?: string; +} + +class UpdateRateDto { + @IsNumber() @Min(0.000001) rate: number; + @IsOptional() @IsString() source?: string; +} + +@ApiTags('Currency') +@Controller('currencies') +export class CurrencyController { + constructor(private readonly currencyService: CurrencyService) {} + + @Get() + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'List all exchange rates' }) + listRates() { + return this.currencyService.listRates(); + } + + @Post() + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Create exchange rate' }) + create(@Body() dto: CreateRateDto) { + return this.currencyService.upsertRate(dto.fromCurrency, dto.toCurrency, dto.rate, undefined, dto.source ?? 'MANUAL'); + } + + @Patch(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Update exchange rate by ID' }) + update(@Param('id') id: string, @Body() dto: UpdateRateDto) { + return this.currencyService.updateRateById(id, dto.rate, dto.source ?? 'MANUAL'); + } + + @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete exchange rate by ID' }) + delete(@Param('id') id: string) { + return this.currencyService.deleteRate(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/currency/currency.module.ts b/apps/edr-passenger-api/src/modules/currency/currency.module.ts index 635ab74b0..4cee92fa3 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.module.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; import { CurrencyService } from './currency.service'; +import { CurrencyController } from './currency.controller'; import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [PrismaModule, HttpModule], + imports: [PrismaModule], + controllers: [CurrencyController], providers: [CurrencyService], exports: [CurrencyService], }) diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 304add11c..806ab423e 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -1,12 +1,4 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, -} from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; @@ -24,11 +16,7 @@ const CHARGE_CURRENCY_DECIMALS: Record = { export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); - constructor( - private readonly prisma: PrismaService, - private readonly httpService: HttpService, - private readonly configService: ConfigService, - ) {} + constructor(private readonly prisma: PrismaService) {} /** * Converts a stored display-currency minor amount to the charge major amount @@ -148,53 +136,6 @@ export class CurrencyService { return Number(exchangeRate.rate); } - async syncExchangeRates(): Promise { - this.logger.log('Syncing exchange rates from central bank API'); - - const today = this.todayUtc(); - // Fallback rates used when the API is unreachable - const fallbackRates = [ - { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, - { from: Currency.ETB, to: Currency.DJF, rate: 3.25 }, - { from: Currency.ETB, to: Currency.USD, rate: 0.018 }, - { from: Currency.DJF, to: Currency.ETB, rate: 0.3077 }, - { from: Currency.USD, to: Currency.ETB, rate: 55.56 }, - ]; - - const apiUrl = this.configService.get('EXCHANGE_RATE_API_URL'); - if (apiUrl) { - try { - const response = await firstValueFrom( - this.httpService.get>(apiUrl, { timeout: 5000 }), - ); - // Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... } - const data = response.data; - const apiRates = [ - { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, - { from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate }, - { from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate }, - { from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate }, - { from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate }, - ]; - for (const { from, to, rate } of apiRates) { - await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API'); - } - this.logger.log('Exchange rates synced from central bank API'); - return; - } catch (err) { - this.logger.warn( - `Central bank API unreachable (${(err as Error).message}), falling back to configured rates`, - ); - } - } - - // Fallback: persist the static rates so the DB always has a current row - for (const { from, to, rate } of fallbackRates) { - await this.upsertRate(from, to, rate, today, 'FALLBACK'); - } - this.logger.log('Exchange rates synced using fallback values'); - } - async listRates() { return this.prisma.currencyExchangeRate.findMany({ orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }], diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts index 0cd1eda71..5a33dcc31 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -49,9 +49,4 @@ export class CurrencyController { return this.currency.deleteRate(id); } - @Post('sync') - @ApiOperation({ summary: 'Trigger exchange rate sync from external provider' }) - sync() { - return this.currency.syncExchangeRates(); - } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 84be880be..2c52db955 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -39,10 +39,18 @@ export class PassengersService { const where: any = {}; if (search) { + // IAM user search: resolve matching iamUserIds first, then filter by passengerId + const iamRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE (name->>'en') ILIKE $1 OR (name->>'am') ILIKE $1 OR email ILIKE $1 OR phone_number ILIKE $1`, + [`%${search}%`], + ); + const matchedPassengers = iamRows.length > 0 + ? await this.prisma.passenger.findMany({ where: { iamUserId: { in: iamRows.map(r => r.id) } }, select: { id: true } }) + : []; + where.OR = [ { fullName: { contains: search, mode: 'insensitive' } }, - { passenger: { user: { email: { contains: search, mode: 'insensitive' } } } }, - { passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } }, + ...(matchedPassengers.length > 0 ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] : []), ]; } @@ -66,7 +74,6 @@ export class PassengersService { include: { passenger: { include: { - user: true, loyalty: true, wallet: true, _count: { select: { bookings: true } }, @@ -106,17 +113,13 @@ export class PassengersService { return { items: items.map(profile => { const passenger = profile.passenger; - const localUser = (passenger as any)?.user ?? null; const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; - const faydaVerified = localUser?.faydaVerified === true - || iam?.metadata?.faydaVerified === true + const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; - // Get additional data from bookings for guest passengers const guestBooking = (passenger as any)?.bookings?.[0] ?? null; const guestSeat = guestBooking?.seats?.[0] ?? null; - // Parse notes JSON to extract phone and other data let notesData: any = null; if (profile.notes) { try { @@ -129,25 +132,23 @@ export class PassengersService { return { id: profile.id, fullName: profile.fullName, - email: localUser?.email ?? iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null, - phone: localUser?.phone ?? iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null, - gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null, + email: iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null, + phone: iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null, + gender: profile.gender ?? iam?.metadata?.gender ?? null, dateOfBirth: profile.dateOfBirth ? new Date(profile.dateOfBirth).toISOString().split('T')[0] - : (localUser?.dateOfBirth - ? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth) - : iam?.metadata?.dateOfBirth ?? null), - nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null), - nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null, + : (iam?.metadata?.dateOfBirth ?? null), + nationality: iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null), + nationalityCode: iam?.metadata?.nationalityCode ?? null, faydaVerified, - faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null, - passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null, - passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null, - passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null, + faydaVerifiedAt: iam?.metadata?.faydaVerifiedAt ?? null, + passportNumber: iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null, + passportCountry: iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null, + passportExpiryDate: iam?.metadata?.passportExpiryDate ?? null, idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null), verified: faydaVerified, - lastLoginAt: localUser?.lastLoginAt ?? null, - role: localUser?.role ?? null, + lastLoginAt: null, + role: null, loyalty: passenger?.loyalty ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 } : null, @@ -279,6 +280,8 @@ export class PassengersService { passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, nationality: p.nationality, + phone: p.phone ?? null, + email: p.email ?? null, })), message: 'Passenger details saved successfully', }; @@ -434,18 +437,12 @@ export class PassengersService { async deletePassenger(id: string, cascade = false) { // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id - let passenger = await this.prisma.passenger.findUnique({ - where: { id }, - include: { user: true }, - }); + let passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) { const profile = await this.prisma.travelerProfile.findUnique({ where: { id } }); if (!profile?.passengerId) throw new NotFoundException('Passenger not found'); - passenger = await this.prisma.passenger.findUnique({ - where: { id: profile.passengerId }, - include: { user: true }, - }); + passenger = await this.prisma.passenger.findUnique({ where: { id: profile.passengerId } }); if (!passenger) throw new NotFoundException('Passenger not found'); } @@ -454,7 +451,7 @@ export class PassengersService { if (!cascade) { const usage = await this.checkPassengerUsage(passengerId); if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; + const passengerName = `Passenger ${passengerId.slice(-8)}`; throw new DeleteOperationException('Passenger', passengerName, usage.constraints); } } diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 401a37ddb..fb9957b92 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -253,18 +253,6 @@ export class TasksService { } } - // ───────────────────────────────────────────────────────────────────────── - // Daily at 01:00 EAT: fetch mid-market rates from central bank API. - // ───────────────────────────────────────────────────────────────────────── - @Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' }) - async syncExchangeRates() { - try { - await this.currencyService.syncExchangeRates(); - } catch (err) { - this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`); - } - } - // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 3133a87e4..4ab5cb9b7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -255,8 +255,8 @@ function BookingsPageContent() { { key: 'contact', label: 'Primary contact', render: (booking: any) => { - const phone = booking.contactPhone || booking.passenger?.phone || '—'; - const email = booking.contactEmail || booking.passenger?.email || '—'; + const phone = booking.contactPhone || booking.passenger?.phone || booking.seats?.[0]?.phone || '—'; + const email = booking.contactEmail || booking.passenger?.email || booking.seats?.[0]?.email || '—'; return (
{phone}
@@ -403,9 +403,9 @@ function BookingsPageContent() {
- - - + + +
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index 3c10de561..f5aed275e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -197,12 +197,7 @@ export default function ClassesPage() {

Classes

Manage class configurations with pricing by coach type

- handleOpenModal()} - > - Add Class - +
diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index edf23692a..7be021368 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,55 +2,43 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { Edit, Loader2, Plus, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; -interface CurrencyRate { +interface ExchangeRate { id: string; - code: string; - name: string; - symbol: string; - baseCurrencyCode: string; - exchangeRate: number; - isActive: boolean; - createdAt: string; + fromCurrency: string; + toCurrency: string; + rate: number; + source: string; + effectiveDate: string; } -const CURRENCY_META: Record = { - ETB: { name: 'Ethiopian Birr', symbol: 'Br' }, - DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' }, - USD: { name: 'US Dollar', symbol: '$' }, +const CURRENCY_META: Record = { + ETB: { name: 'Ethiopian Birr' }, + DJF: { name: 'Djiboutian Franc' }, + USD: { name: 'US Dollar' }, }; +const CURRENCY_OPTIONS = ['ETB', 'DJF', 'USD']; + export default function CurrenciesPage() { - const [editingRate, setEditingRate] = useState(null); - const [rateInput, setRateInput] = useState(''); - const [error, setError] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); - const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' }); - const [deleteConfirm, setDeleteConfirm] = useState(null); + const [editingRate, setEditingRate] = useState(null); + const [rateInput, setRateInput] = useState(''); + const [error, setError] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [addForm, setAddForm] = useState({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' }); + const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); - const { data: currencies = [], isLoading } = useQuery({ + const { data: rates = [], isLoading } = useQuery({ queryKey: ['currencies'], queryFn: () => apiClient.get('/currencies'), - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) => - apiClient.patch(`/currencies/${id}`, { exchangeRate }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setEditingRate(null); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update exchange rate'); - }, + select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []), }); const createMutation = useMutation({ @@ -58,10 +46,21 @@ export default function CurrenciesPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['currencies'] }); setShowAddModal(false); - setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' }); + setAddForm({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' }); setError(null); }, - onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'), + onError: (err: any) => setError(err.response?.data?.message || 'Failed to create rate'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, rate }: { id: string; rate: number }) => + apiClient.patch(`/currencies/${id}`, { rate }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currencies'] }); + setEditingRate(null); + setError(null); + }, + onError: (err: any) => setError(err.response?.data?.message || 'Failed to update rate'), }); const deleteMutation = useMutation({ @@ -70,88 +69,70 @@ export default function CurrenciesPage() { queryClient.invalidateQueries({ queryKey: ['currencies'] }); setDeleteConfirm(null); }, - onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'), + onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete rate'), }); - const syncMutation = useMutation({ - mutationFn: () => apiClient.post('/currencies/sync-rates', {}), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), - onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'), - }); - - const handleEdit = (currency: CurrencyRate) => { - setEditingRate(currency); - setRateInput(currency.exchangeRate.toString()); - setError(null); - }; - - const handleSave = async () => { - const rate = parseFloat(rateInput); - if (isNaN(rate) || rate <= 0) { - setError('Exchange rate must be a positive number'); - return; - } - await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate }); - }; - - const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? []; - const columns = [ { - key: 'code', - label: 'Currency', - render: (c: CurrencyRate) => ( -
- - {CURRENCY_META[c.code]?.symbol ?? c.symbol} + key: 'pair', + label: 'Pair', + render: (r: ExchangeRate) => ( +
+ {r.fromCurrency} + + {r.toCurrency} + + {CURRENCY_META[r.toCurrency]?.name ?? r.toCurrency} -
-
{c.code}
-
{CURRENCY_META[c.code]?.name ?? c.name}
-
), }, { - key: 'baseCurrencyCode', - label: 'Base', - render: (c: CurrencyRate) => ( - {c.baseCurrencyCode} - ), - }, - { - key: 'exchangeRate', - label: 'Exchange Rate', - render: (c: CurrencyRate) => ( + key: 'rate', + label: 'Rate', + render: (r: ExchangeRate) => (
- 1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code} -
-
- 1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode} + 1 {r.fromCurrency} = {r.rate} {r.toCurrency}
+ {r.rate > 0 && ( +
+ 1 {r.toCurrency} = {(1 / r.rate).toFixed(6)} {r.fromCurrency} +
+ )}
), }, { - key: 'createdAt', - label: 'Last Updated', - render: (c: CurrencyRate) => ( + key: 'source', + label: 'Source', + render: (r: ExchangeRate) => ( + {r.source ?? '—'} + ), + }, + { + key: 'effectiveDate', + label: 'Effective Date', + render: (r: ExchangeRate) => ( - {new Date(c.createdAt).toLocaleDateString()} + {r.effectiveDate ? new Date(r.effectiveDate).toLocaleDateString() : '—'} ), }, ]; const actions = [ - { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, + { + label: 'Edit', + onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); }, + variant: 'secondary' as const, + icon: Edit, + }, { label: 'Delete', - onClick: (c: CurrencyRate) => setDeleteConfirm(c), + onClick: (r: ExchangeRate) => setDeleteConfirm(r), variant: 'danger' as const, icon: Trash2, - show: (c: CurrencyRate) => c.id !== 'etb-base', }, ]; @@ -160,64 +141,27 @@ export default function CurrenciesPage() {

Exchange Rates

-

- Manage ETB exchange rates for display currencies (DJF, USD) -

-
-
- { setError(null); setShowAddModal(true); }}>Add Currency - syncMutation.mutate()} - loading={syncMutation.isPending} - > - Sync Rates - +

Manage currency exchange rates

+ { setError(null); setShowAddModal(true); }}> + Add Rate +
- {error && !editingRate && ( + {error && !editingRate && !showAddModal && (
{error}
)}
-
- {(['ETB', 'DJF', 'USD'] as const).map((code) => { - const entry = currenciesArray.find((c: CurrencyRate) => c.code === code); - return ( -
-
-
{CURRENCY_META[code].name}
-
{code}
-
-
- {entry ? ( - <> -
{entry.exchangeRate}
-
per ETB
- - ) : ( - Not configured - )} -
-
- ); - })} -
- {isLoading ? (
) : ( - { setShowAddModal(false); setError(null); }} - title="Add Currency" - size="sm" - > + {/* Add Modal */} + { setShowAddModal(false); setError(null); }} title="Add Exchange Rate" size="sm">
{error && (
{error}
)}
- - setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} /> + +
- - setAddForm({ ...addForm, symbol: e.target.value })} /> + +
- - setAddForm({ ...addForm, name: e.target.value })} /> -
-
- - setAddForm({ ...addForm, exchangeRate: e.target.value })} /> + + setAddForm({ ...addForm, rate: e.target.value })} />
{ setShowAddModal(false); setError(null); }}>Cancel - { - if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) { - setError('All fields are required'); return; - } - const rate = parseFloat(addForm.exchangeRate); - if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; } - createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate }); - }} - > - Add Currency + { + if (addForm.fromCurrency === addForm.toCurrency) { setError('From and To currencies must differ'); return; } + const rate = parseFloat(addForm.rate); + if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; } + createMutation.mutate({ fromCurrency: addForm.fromCurrency, toCurrency: addForm.toCurrency, rate }); + }}> + Add Rate
+ {/* Edit Modal */} + { setEditingRate(null); setError(null); }} title={`Edit Rate — ${editingRate?.fromCurrency} → ${editingRate?.toCurrency}`} size="sm"> +
+ {error && ( +
{error}
+ )} +
+ + setRateInput(e.target.value)} autoFocus /> + {rateInput && parseFloat(rateInput) > 0 && ( +

+ ≈ 1 {editingRate?.toCurrency} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.fromCurrency} +

+ )} +
+
+ { setEditingRate(null); setError(null); }}>Cancel + { + const rate = parseFloat(rateInput); + if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; } + updateMutation.mutate({ id: editingRate!.id, rate }); + }}> + Save + +
+
+
+ + {/* Delete Confirm */} setDeleteConfirm(null)} onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)} - title="Delete Currency" - message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`} + title="Delete Exchange Rate" + message={`Delete rate ${deleteConfirm?.fromCurrency} → ${deleteConfirm?.toCurrency} (${deleteConfirm?.rate})?`} confirmText="Delete" isDanger isLoading={deleteMutation.isPending} /> - - { setEditingRate(null); setError(null); }} - title={`Update Rate — ${editingRate?.code}`} - size="sm" - > -
- {error && ( -
- {error} -
- )} - -
- Currency: - {editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name} -
- -
- - setRateInput(e.target.value)} - className="input w-full" - placeholder="e.g., 3.25" - autoFocus - /> - {rateInput && parseFloat(rateInput) > 0 && ( -

- ≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode} -

- )} -
- -
- { setEditingRate(null); setError(null); }}> - Cancel - - - Save Rate - -
-
-
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index e2c0fefb5..1a3fcc656 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -140,7 +140,7 @@ export default function PassengersPage() {
), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || '—' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index c737da4de..21b6e6253 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -189,7 +189,7 @@ export default function TicketsPage() { const coach = ticket.seat?.coach?.number || 'N/A'; const bookingRef = ticket.booking?.bookingRef || 'N/A'; const ticketNum = ticket.ticketNumber || 'N/A'; - const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest'; + const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.seats?.[0]?.passengerName || ticket.passengerName || 'Guest'; w.document.write( 'Boarding Pass