import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma.service'; import { MeLikeUser } from '../passenger-permission.util'; import { normalizePhone, samePhone } from './phone.utils'; /** * Shared by every flow that lets a passenger change a confirmed booking — reschedule today, * fare-class upgrade next. These were private to RescheduleService; they live here so the two * features cannot drift apart on who is allowed to act or how a seat is priced. * * Plain functions rather than a provider on purpose: AuditService injects REQUEST, so anything * made injectable here would drag request scope into whatever consumes it. */ export type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string }; /** * The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an * empty string, so `iam.users` is the source of truth — and reading it live also means a user * who changed their number does not have to sign out before the new one counts. */ export async function resolveUserPhone( prisma: PrismaService, iamUserId: string, user: ActingUser, ): Promise { const fromSession = normalizePhone(user.phoneNumber); if (fromSession) return fromSession; const rows = await prisma.$queryRaw<{ phone_number: string | null }[]>` SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1 `; return normalizePhone(rows[0]?.phone_number); } /** * Loads a booking only for the person who made it, proven by their account's phone number * matching the booking's `contactPhone`. Being merely *named* on the booking is not enough — a * passenger travelling on someone else's booking cannot change it. * * There is deliberately no staff override. `bookings:reschedule` exists in the registry (and on * the stationMaster preset) but is not honoured, so a station master cannot act on a customer's * behalf yet. * * `action` only shapes the error message ("reschedule it" / "upgrade it"). */ export async function loadOwnedBooking( prisma: PrismaService, bookingRef: string, user: ActingUser, include: T, action = 'change it', ) { const booking = await prisma.booking.findUnique({ where: { bookingRef }, include }); if (!booking) throw new NotFoundException('Booking not found'); const iamUserId = user.id ?? user.sub; if (!iamUserId) throw new ForbiddenException(); const b = booking as any; if (b.contactPhone) { const callerPhone = await resolveUserPhone(prisma, iamUserId, user); if (samePhone(callerPhone, b.contactPhone)) return booking; throw new ForbiddenException( `Only the person who made this booking can ${action}. Sign in with the phone number used to book.`, ); } // A small tail of bookings carry no contactPhone at all, so there is nothing to match against. // Fall back to the account link rather than locking their owner out entirely. const passenger = await prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); if (!passenger || passenger.id !== b.passengerId) throw new ForbiddenException('Not your booking'); return booking; } /** * Coaches nobody buys a seat in, so they can never carry a fare-class policy. * * Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' | * 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space * included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the * dining coach as a fare class. Mirrors the portal's own test (`/dining|dpc/i`). */ export const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage']; export const NOT_A_FARE_CLASS = { NOT: NON_FARE_COACH_TERMS.flatMap((term) => [ { type: { contains: term, mode: 'insensitive' as const } }, { code: { contains: term, mode: 'insensitive' as const } }, ]), }; /** True when this coach type is a dining/baggage coach rather than a sellable fare class. */ export function isNonFareCoachType(coachType: { type?: string | null; code?: string | null }): boolean { const haystack = `${coachType.type ?? ''} ${coachType.code ?? ''}`.toLowerCase(); return NON_FARE_COACH_TERMS.some((t) => haystack.includes(t)); } /** * Nationality is not stored on the booking, so the display currency is the proxy the search and * fare code already use: ETB/DJF are local tariffs, USD is the international one. Both flows must * use the same proxy or an upgrade would be priced on a different tariff than the original sale. */ export function resolveNationalityProxy(displayCurrency?: string | null): { nationalityType: 'LOCAL' | 'INTERNATIONAL'; nationality: string | undefined; } { return { nationalityType: displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL', nationality: displayCurrency === 'DJF' ? 'Djiboutian' : displayCurrency === 'ETB' ? 'Ethiopian' : undefined, }; } /** * Mirrors SearchService's class matching: nationality filter, then bed position. * `Seat.bedPosition` is lowercase and `SeatClass.bedPosition` uppercase, hence the folding. */ export function pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) { const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType); const pool = byNat.length ? byNat : classes; const bed = bedPosition?.toLowerCase() ?? null; const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed); return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null; } /** * Distributes a leg fare over seats; free children (fare 0) stay 0 and rounding lands on the last * paid seat. */ export function splitFare(total: number, seats: Array<{ fareMinor: number | null }>): number[] { const paid = seats.map((s) => s.fareMinor !== 0); const n = paid.filter(Boolean).length || 1; const each = Math.floor(total / n); let remaining = total; let lastPaid = -1; const out = seats.map((_, i) => { if (!paid[i]) return 0; lastPaid = i; remaining -= each; return each; }); if (lastPaid >= 0) out[lastPaid] += remaining; return out; }