import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; interface PassengerFilters { search?: string; gender?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number; } type IamUserRow = { id: string; email: string; name: { en: string; am: string } | null; phone_number: string | null; metadata: Record | null; verified_by?: string | null; }; @Injectable() export class PassengersService { private readonly logger = new Logger(PassengersService.name); constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private readonly verifaydaService: VerifaydaService, private readonly auditService: AuditService, ) {} async findAll(filters: PassengerFilters = {}) { const { search, gender, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; 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' } }, ...(matchedPassengers.length > 0 ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] : []), ]; } if (gender) { where.gender = gender; } if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), }; } const [items, total] = await Promise.all([ this.prisma.travelerProfile.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { passenger: { include: { loyalty: true, wallet: true, _count: { select: { bookings: true } }, bookings: { take: 1, orderBy: { createdAt: 'desc' }, select: { contactPhone: true, contactEmail: true, seats: { take: 1, orderBy: { id: 'asc' }, select: { passengerName: true, passportNumber: true, passportCountry: true, }, }, }, }, }, }, }, }), this.prisma.travelerProfile.count({ where }), ]); const iamUserIds = items.map(p => p.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`, [iamUserIds], ) : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); return { items: items.map(profile => { const passenger = profile.passenger; const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; const guestBooking = (passenger as any)?.bookings?.[0] ?? null; const guestSeat = guestBooking?.seats?.[0] ?? null; let notesData: any = null; if (profile.notes) { try { notesData = typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes; } catch { notesData = null; } } return { id: profile.id, fullName: profile.fullName, 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] : (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: 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: null, role: null, loyalty: passenger?.loyalty ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 } : null, wallet: (passenger as any)?.wallet ? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' } : null, loyaltyTier: passenger?.loyalty?.tier || 'BRONZE', loyaltyPoints: passenger?.loyalty?.pointsBalance || 0, totalBookings: passenger?._count?.bookings || 0, createdAt: profile.createdAt, }; }), meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, }; } async getProfile(passengerId: string) { const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, }, }, loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, }, }); if (!passenger) throw new NotFoundException('Passenger not found'); let iamUser: IamUserRow | null = null; if (passenger.iamUserId) { const rows = await this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, [passenger.iamUserId], ); iamUser = rows[0] ?? null; } return { id: passenger.id, fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, email: iamUser?.email ?? null, phone: iamUser?.phone_number ?? null, createdAt: passenger.createdAt, bookings: passenger.bookings.map((b) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt, trip: { number: b.schedule.train.number, origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city }, destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city }, departureAt: b.schedule.departureAt, }, passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' }, })), })), }; } /** * Full passenger-form payload for the logged-in user, sourced from the IAM user record * (iam.users) — where the Fayda-verified identity actually lives — rather than the sparse * Passenger row. The booking passenger form (/booking/passengers) calls this via * GET /passengers/me to prefill (and lock) an already-verified passenger's details. * * Identity fields don't depend on a Passenger row existing; only `id` (used later to tag the * primary passenger on the booking) does, and it's null if no Passenger row is linked yet. */ async getMyProfile(iamUserId: string) { const [passenger, iamRows] = await Promise.all([ this.prisma.passenger.findUnique({ where: { iamUserId } }), this.dataSource.query( `SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ), ]); const iam = iamRows[0] ?? null; if (!iam && !passenger) return null; const meta = iam?.metadata ?? {}; const faydaVerified = iam?.verified_by === 'fayda' || meta.faydaVerified === true || meta.faydaVerified === 'true'; // Fayda writes gender as { am, en }; older/manual records may store a plain string. const gender = meta.gender && typeof meta.gender === 'object' ? (meta.gender.en ?? meta.gender.am ?? null) : (meta.gender ?? null); const fullName = iam?.name?.en ?? iam?.name?.am ?? null; // Stored as ISO by the Fayda upsert; tolerate a "/"-separated legacy value. const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null; const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null; // Nationality isn't always in metadata; a Fayda-verified holder is Ethiopian by definition. const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null); return { id: passenger?.id ?? null, fullName, email: iam?.email ?? meta.email ?? null, phone: iam?.phone_number ?? meta.phoneNumber ?? null, gender, dateOfBirth, nationality, faydaVerified, faydaSub: meta.sub ?? null, passportNumber: meta.passportNumber ?? null, passportCountry: meta.passportCountry ?? null, passportIssueDate: meta.passportIssueDate ?? null, passportExpiryDate: meta.passportExpiryDate ?? null, passportIssuingAuthority: meta.passportIssuingAuthority ?? null, }; } async getStats(passengerId: string) { const [totalTrips, totalSpendResult, loyalty] = await Promise.all([ this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }), this.prisma.booking.aggregate({ where: { passengerId, status: 'BOARDED' as any }, _sum: { totalMinor: true } }), this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }), ]); const totalSpend = ((totalSpendResult._sum?.totalMinor ?? 0) as number) / 100; return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 }; } async savePassengers(passengers: any[], userId?: string, deviceId?: string) { if (!passengers || !Array.isArray(passengers)) { throw new BadRequestException('Passengers array is required'); } if (passengers.length === 0) { throw new BadRequestException('At least one passenger is required'); } const savedProfiles = await Promise.all( passengers.map((p) => this.prisma.savedPassengerProfile.create({ data: { userId, deviceId, passengerName: p.name || p.passengerName, dateOfBirth: new Date(p.dateOfBirth), idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT', passportNumber: p.passportNumber, passportCountry: p.passportCountry, nationality: p.nationality, phone: p.phone, email: p.email, }, }) ) ); return { count: savedProfiles.length, passengerIds: savedProfiles.map(p => p.id), passengers: savedProfiles.map(p => ({ id: p.id, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, nationality: p.nationality, phone: p.phone ?? null, email: p.email ?? null, })), message: 'Passenger details saved successfully', }; } createTravelerProfile(dto: CreateTravelerProfileDto) { return this.prisma.travelerProfile.create({ data: { passengerId: dto.passengerId, fullName: dto.fullName, relationship: dto.relationship, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null, nationalId: dto.nationalId || null, gender: dto.gender || null, notes: dto.notes || null, } }); } getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); } createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); } getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } async updatePassenger(id: string, dto: any) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) { const updates: string[] = []; const params: any[] = []; let idx = 1; if (dto.fullName) { updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`); params.push(dto.fullName); idx++; } if (dto.email) { updates.push(`email = $${idx}`); params.push(dto.email); idx++; } if (dto.phone) { updates.push(`phone_number = $${idx}`); params.push(dto.phone); idx++; } params.push(passenger.iamUserId); await this.dataSource.query( `UPDATE iam.users SET ${updates.join(', ')} WHERE id = $${idx}`, params, ); } const [updated, iamRows] = await Promise.all([ this.prisma.passenger.findUnique({ where: { id }, include: { loyalty: true } }), passenger.iamUserId ? this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, [passenger.iamUserId], ) : Promise.resolve([] as IamUserRow[]), ]); const iamUser = iamRows[0] ?? null; return { id: updated!.id, fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, email: iamUser?.email ?? null, phone: iamUser?.phone_number ?? null, loyalty: updated!.loyalty, }; } async registerPassenger(dto: RegisterPassengerDto) { const isEthiopian = !!dto.nationalId; const isLoggedIn = !!dto.userId; let verifiedData: any = null; if (isEthiopian && dto.verifyWithFayda !== false) { try { const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!); if (verification.verified && verification.passengerData) { verifiedData = verification.passengerData; } } catch (error) { this.logger.warn('Fayda verification failed, using manual data:', error); } } const finalData = { passengerName: verifiedData?.fullName || dto.passengerName, dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth), nationality: verifiedData?.nationality || dto.nationality || (isEthiopian ? 'Ethiopian' : null), gender: verifiedData?.gender || dto.gender, phone: dto.phone, email: dto.email, }; if (isLoggedIn) { const linkedPassenger = await this.prisma.passenger.findUnique({ where: { iamUserId: dto.userId }, }); if (!linkedPassenger) { throw new BadRequestException('Passenger not found'); } return { id: linkedPassenger.id, passengerName: finalData.passengerName, dateOfBirth: finalData.dateOfBirth, nationality: finalData.nationality, verified: !!verifiedData, linked: true, message: 'Passenger details saved and linked to user account', }; } const profile = await this.prisma.savedPassengerProfile.create({ data: { deviceId: dto.deviceId, passengerName: finalData.passengerName, dateOfBirth: finalData.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', passportNumber: dto.passportNumber, passportCountry: dto.passportCountry, nationality: finalData.nationality, phone: dto.phone, email: dto.email, }, }); return { id: profile.id, passengerName: finalData.passengerName, dateOfBirth: finalData.dateOfBirth, nationality: finalData.nationality, verified: !!verifiedData, linked: false, message: 'Passenger details saved for guest booking', }; } 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 } }); 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 } }); if (!passenger) throw new NotFoundException('Passenger not found'); } const passengerId = passenger.id; if (!cascade) { const usage = await this.checkPassengerUsage(passengerId); if (usage.isInUse && usage.constraints) { const passengerName = `Passenger ${passengerId.slice(-8)}`; throw new DeleteOperationException('Passenger', passengerName, usage.constraints); } } // Resolve booking IDs first (needed for multi-step child deletion) const bookings = await this.prisma.booking.findMany({ where: { passengerId }, select: { id: true }, }); const bookingIds = bookings.map(b => b.id); if (bookingIds.length > 0) { const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); if (tickets.length > 0) { await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } }); } await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); if (foodOrders.length > 0) { await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } }); } await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); if (paymentIntents.length > 0) { await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } }); } await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } }); } // Package bookings const packageBookings = await this.prisma.packageBooking.findMany({ where: { passengerId }, select: { id: true } }); if (packageBookings.length > 0) { const pbIds = packageBookings.map(pb => pb.id); await this.prisma.packageBookingPassenger.deleteMany({ where: { bookingId: { in: pbIds } } }); await this.prisma.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: pbIds } } }); await this.prisma.packageBooking.deleteMany({ where: { id: { in: pbIds } } }); } await this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }); await this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }); await this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }); await this.prisma.walletAccount.deleteMany({ where: { passengerId } }); await this.prisma.notification.deleteMany({ where: { passengerId } }); await this.prisma.travelerProfile.deleteMany({ where: { passengerId } }); await this.prisma.savedRoute.deleteMany({ where: { passengerId } }); await this.prisma.passenger.delete({ where: { id: passengerId } }); await this.auditService.log({ action: 'DELETE', entityType: 'Passenger', entityId: passengerId }); return { deleted: true, passengerId }; } async checkPassengerUsage(id: string) { const [bookingCount, loyaltyAccount, walletAccount] = await Promise.all([ this.prisma.booking.count({ where: { passengerId: id } }), this.prisma.loyaltyAccount.findUnique({ where: { passengerId: id } }), this.prisma.walletAccount.findUnique({ where: { passengerId: id } }), ]); const constraints = []; if (bookingCount > 0) constraints.push({ entityName: 'booking', count: bookingCount, action: 'complete' as const }); if (loyaltyAccount) constraints.push({ entityName: 'loyalty account', count: 1, action: 'delete' as const }); if (walletAccount) constraints.push({ entityName: 'wallet account', count: 1, action: 'delete' as const }); return { isInUse: constraints.length > 0, affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`), constraints }; } }