Files
edr-platform/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts

145 lines
6.1 KiB
TypeScript

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<string | null> {
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<T extends Prisma.BookingInclude>(
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;
}