mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 00:43:38 +00:00
145 lines
6.1 KiB
TypeScript
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;
|
|
}
|