mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 09:28:19 +00:00
feat(group-booking): draft schedules, coach-scoped seating, mixed classes
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
-- Adds the DRAFT value to passenger."TripStatus".
|
||||||
|
--
|
||||||
|
-- This migration deliberately contains NOTHING ELSE. Postgres cannot use a newly added enum
|
||||||
|
-- value inside the same transaction that adds it, and Prisma wraps each migration file in one
|
||||||
|
-- transaction — so any DDL or DML that references 'DRAFT' must live in a LATER migration file.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS makes it idempotent, so a re-run (or a database where it was applied out of
|
||||||
|
-- band) is a no-op rather than a failure.
|
||||||
|
--
|
||||||
|
-- BEFORE 'SCHEDULED' keeps the physical enum order identical to the declaration order in
|
||||||
|
-- schema.prisma, so `prisma migrate diff` sees no drift. (PG 15 here; ADD VALUE ... BEFORE has
|
||||||
|
-- been available since 9.1 and IF NOT EXISTS since 12.)
|
||||||
|
ALTER TYPE passenger."TripStatus" ADD VALUE IF NOT EXISTS 'DRAFT' BEFORE 'SCHEDULED';
|
||||||
@@ -20,6 +20,10 @@ enum UserRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum TripStatus {
|
enum TripStatus {
|
||||||
|
/// Not yet published. Hidden from the public booking channel; visible to the staff
|
||||||
|
/// group-booking channel so a party can be assembled before the trip goes on sale.
|
||||||
|
/// Promoted to SCHEDULED by an explicit publish action — never by the status cron.
|
||||||
|
DRAFT
|
||||||
SCHEDULED
|
SCHEDULED
|
||||||
BOARDING
|
BOARDING
|
||||||
EN_ROUTE
|
EN_ROUTE
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Values written to `Booking.source`.
|
||||||
|
*
|
||||||
|
* `Booking.source` is a plain String column defaulting to `'WEB'`, so these are conventions
|
||||||
|
* rather than an enum. They are the only way to tell afterwards HOW a booking was made, which
|
||||||
|
* several behaviours now depend on:
|
||||||
|
*
|
||||||
|
* - `BACKOFFICE_RESERVATION` suppresses the generic `booking.created` notification, because the
|
||||||
|
* reservation flow sends its own pay-link message (notifications.service.ts).
|
||||||
|
* - `BACKOFFICE_GROUP` marks a staff group booking. Its seat allocation was chosen deliberately
|
||||||
|
* by a person, so ticket generation must not silently re-seat it, and reporting can finally
|
||||||
|
* distinguish a real group booking from a family that happened to book several seats.
|
||||||
|
*/
|
||||||
|
export const BOOKING_SOURCE_WEB = 'WEB';
|
||||||
|
export const BOOKING_SOURCE_BACKOFFICE_RESERVATION = 'BACKOFFICE_RESERVATION';
|
||||||
|
export const BOOKING_SOURCE_GROUP = 'BACKOFFICE_GROUP';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sources whose seat allocation was picked by a human and must be preserved.
|
||||||
|
*
|
||||||
|
* `TicketsService.smartAssignAndGenerate` reassigns a seat that has become unavailable by the
|
||||||
|
* time tickets are issued. That is a sensible rescue for a self-service booking, but for a
|
||||||
|
* staff-chosen allocation it silently undoes the choice — moving one member of a group away from
|
||||||
|
* the coach the rest of the party is sitting in, after the customer was shown the allocation.
|
||||||
|
*/
|
||||||
|
export const HUMAN_ALLOCATED_BOOKING_SOURCES: readonly string[] = [
|
||||||
|
BOOKING_SOURCE_GROUP,
|
||||||
|
BOOKING_SOURCE_BACKOFFICE_RESERVATION,
|
||||||
|
];
|
||||||
@@ -37,6 +37,7 @@ import { JwtGuard } from "../../common/jwt.guard";
|
|||||||
import { PassengerDelete, PassengerStaff, PassengerStaffStrict, PassengerWrite } from "../../common/passenger-guards";
|
import { PassengerDelete, PassengerStaff, PassengerStaffStrict, PassengerWrite } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { SeatsService } from "../seats/seats.service";
|
import { SeatsService } from "../seats/seats.service";
|
||||||
|
import { BOOKING_SOURCE_GROUP } from "./booking-source.constants";
|
||||||
|
|
||||||
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
|
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
|
||||||
|
|
||||||
@@ -390,7 +391,17 @@ If booking creation fails after the seats were already held, every hold involved
|
|||||||
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
||||||
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
||||||
try {
|
try {
|
||||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
|
// skipIdentityVerification and bookingSource are internal-only fields: the ValidationPipe
|
||||||
|
// strips them from the request body (they carry no validators), so they can only be set
|
||||||
|
// here, after validation. `source` makes the booking identifiable afterwards — for
|
||||||
|
// reporting, for the notification template, and so ticket generation knows not to
|
||||||
|
// re-seat a staff-chosen allocation.
|
||||||
|
return await this.guestService.createGuestBooking({
|
||||||
|
...dto,
|
||||||
|
bookingType: dto.bookingType || "ONE_WAY",
|
||||||
|
skipIdentityVerification: true,
|
||||||
|
bookingSource: BOOKING_SOURCE_GROUP,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
||||||
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
||||||
|
|||||||
@@ -169,14 +169,33 @@ export class CreateGuestBookingDto {
|
|||||||
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||||
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
|
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
// ── Internal-only fields ───────────────────────────────────────────────────────────────
|
||||||
description:
|
// Everything below is set by a controller AFTER validation, never by the client.
|
||||||
'Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB, nationality). ' +
|
//
|
||||||
'For staff-entered/bulk-uploaded rosters (e.g. group bookings) where there is no live Fayda identity ' +
|
// They deliberately carry NO class-validator decorators. The global ValidationPipe runs with
|
||||||
'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' +
|
// `whitelist: true` (main.ts), which strips any property that has no validation decorator —
|
||||||
'(overwriting the real name) or, once configured, would reject the whole booking on a non-match.',
|
// so a value sent in the request body is discarded before it ever reaches the service.
|
||||||
})
|
//
|
||||||
@IsOptional() @IsBoolean() skipIdentityVerification?: boolean;
|
// This matters: `POST /bookings/guest` is a public, unauthenticated endpoint sharing this DTO
|
||||||
|
// with the staff-only `POST /bookings/group`. While `skipIdentityVerification` was a decorated,
|
||||||
|
// client-settable field, anyone could send `{"skipIdentityVerification": true}` to the public
|
||||||
|
// endpoint and bypass Verifayda national-ID verification entirely.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB,
|
||||||
|
* nationality). Set only by `POST /bookings/group`: a staff-uploaded roster has no live Fayda
|
||||||
|
* identity flow to verify against, and calling Verifayda for typed-in ID numbers either returns
|
||||||
|
* dev-mode mock data (overwriting the real name) or would reject the whole booking on a
|
||||||
|
* non-match.
|
||||||
|
*/
|
||||||
|
skipIdentityVerification?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value written to `Booking.source`. Set only by staff entry points — `BACKOFFICE_GROUP` for
|
||||||
|
* `POST /bookings/group`. Leave unset for guest/authenticated bookings so they keep the
|
||||||
|
* schema default of `'WEB'`.
|
||||||
|
*/
|
||||||
|
bookingSource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SavedPassengerProfileDto {
|
export class SavedPassengerProfileDto {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PaymentsService } from '../payments/payments.service';
|
|||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
||||||
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
||||||
|
import { pickSeatClass } from '../../common/utils/booking-change.utils';
|
||||||
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||||
import { JourneyDirection } from '../seats/seats.dto';
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||||
@@ -112,6 +113,98 @@ export class GuestBookingService {
|
|||||||
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
|
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
|
||||||
* nothing is persisted.
|
* nothing is persisted.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The authoritative ETB fare for a party whose members may sit in DIFFERENT classes.
|
||||||
|
*
|
||||||
|
* The single-class floor (`baseFare(dto.seatClassId) × headcount`) is correct only while every
|
||||||
|
* traveller is in the same class. A staff group booking can now put chefs in a VIP berth and
|
||||||
|
* the rest in Regular Seat within one PNR, and for those the single-class floor is simply the
|
||||||
|
* wrong number: measured against the priciest class it rejects an honest total, and against the
|
||||||
|
* cheapest it under-defends.
|
||||||
|
*
|
||||||
|
* So when the party genuinely spans classes, the floor becomes the sum of each seated
|
||||||
|
* traveller's OWN class fare, resolved server-side from the seat they occupy — the client's
|
||||||
|
* `seatFareMinor` is never trusted for this. `pickSeatClass` is the same seat→class resolution
|
||||||
|
* search results and the reservation-issue path already use.
|
||||||
|
*
|
||||||
|
* Returns null when the party is single-class (or nothing is seated), which is the signal to
|
||||||
|
* keep the existing code path untouched — every booking that works today takes that branch and
|
||||||
|
* is byte-for-byte unchanged.
|
||||||
|
*/
|
||||||
|
private async resolveMixedClassFloorMinor(args: {
|
||||||
|
scheduleId: string;
|
||||||
|
bookedSeatClassId: string;
|
||||||
|
seatIds: string[];
|
||||||
|
nationality?: string;
|
||||||
|
segmentRoute?: string;
|
||||||
|
fullRoute?: string;
|
||||||
|
originStationId?: string;
|
||||||
|
destinationStationId?: string;
|
||||||
|
}): Promise<number | null> {
|
||||||
|
if (args.seatIds.length === 0) return null;
|
||||||
|
|
||||||
|
const seats = await this.prisma.seat.findMany({
|
||||||
|
where: { id: { in: args.seatIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bedPosition: true,
|
||||||
|
coach: { select: { coachType: { select: { seatClasses: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (seats.length === 0) return null;
|
||||||
|
|
||||||
|
const nationalityUpper = (args.nationality ?? '').toUpperCase();
|
||||||
|
const nationalityType =
|
||||||
|
nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
|
||||||
|
|
||||||
|
const classIdBySeatId = new Map<string, string>();
|
||||||
|
for (const seat of seats) {
|
||||||
|
const resolved = pickSeatClass(
|
||||||
|
seat.coach?.coachType?.seatClasses ?? [],
|
||||||
|
seat.bedPosition,
|
||||||
|
nationalityType,
|
||||||
|
);
|
||||||
|
// A seat we cannot resolve a class for tells us nothing; fall back to the single-class
|
||||||
|
// path rather than inventing a floor from incomplete data.
|
||||||
|
if (!resolved?.id) return null;
|
||||||
|
classIdBySeatId.set(seat.id, resolved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distinctClassIds = new Set(classIdBySeatId.values());
|
||||||
|
const isSingleClass =
|
||||||
|
distinctClassIds.size === 1 && distinctClassIds.has(args.bookedSeatClassId);
|
||||||
|
if (isSingleClass) return null; // unchanged behaviour for every existing caller
|
||||||
|
|
||||||
|
// One getBaseFare per distinct class, not per passenger — a 40-person group spans at most a
|
||||||
|
// handful of classes, and this runs before the seat lock is taken.
|
||||||
|
const fareByClassId = new Map<string, number>();
|
||||||
|
for (const classId of distinctClassIds) {
|
||||||
|
fareByClassId.set(
|
||||||
|
classId,
|
||||||
|
await this.getBaseFare(
|
||||||
|
args.scheduleId,
|
||||||
|
classId,
|
||||||
|
args.segmentRoute,
|
||||||
|
args.fullRoute,
|
||||||
|
args.nationality,
|
||||||
|
args.originStationId,
|
||||||
|
args.destinationStationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let floor = 0;
|
||||||
|
for (const seatId of args.seatIds) {
|
||||||
|
const classId = classIdBySeatId.get(seatId);
|
||||||
|
if (classId) floor += fareByClassId.get(classId) ?? 0;
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Mixed-class booking on schedule ${args.scheduleId}: ${distinctClassIds.size} classes across ` +
|
||||||
|
`${args.seatIds.length} seats — authoritative floor ${floor} (per-seat), not single-class`,
|
||||||
|
);
|
||||||
|
return floor;
|
||||||
|
}
|
||||||
|
|
||||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||||
@@ -353,7 +446,22 @@ export class GuestBookingService {
|
|||||||
: displayTotalMinor;
|
: displayTotalMinor;
|
||||||
|
|
||||||
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
|
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
|
||||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking');
|
// A party spanning several classes (staff group bookings can now do this) needs a floor built
|
||||||
|
// from each traveller's own seat class; otherwise the single-class figure above stands.
|
||||||
|
const mixedClassFloor = isPackageOneway
|
||||||
|
? null
|
||||||
|
: await this.resolveMixedClassFloorMinor({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
bookedSeatClassId: dto.seatClassId,
|
||||||
|
seatIds: claimedSeatIds,
|
||||||
|
nationality: passengersData[0]?.nationality,
|
||||||
|
segmentRoute,
|
||||||
|
fullRoute,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
});
|
||||||
|
const authoritativeMinor = Math.max(0, (mixedClassFloor ?? totalBaseFareMinor) - discountMinor);
|
||||||
|
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, authoritativeMinor, 'createGuestBooking');
|
||||||
|
|
||||||
// Resolve or create the guest Passenger record
|
// Resolve or create the guest Passenger record
|
||||||
const firstPassenger = passengersData[0];
|
const firstPassenger = passengersData[0];
|
||||||
@@ -406,6 +514,9 @@ export class GuestBookingService {
|
|||||||
displayTotalMinor,
|
displayTotalMinor,
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
|
// Staff entry points stamp their own source (e.g. BACKOFFICE_GROUP); guest and
|
||||||
|
// authenticated bookings leave it unset and keep the schema default of 'WEB'.
|
||||||
|
...(dto.bookingSource ? { source: dto.bookingSource } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: contact.contactEmail,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: contact.contactPhone,
|
contactPhone: contact.contactPhone,
|
||||||
@@ -944,7 +1055,42 @@ export class GuestBookingService {
|
|||||||
: displayTotalMinor;
|
: displayTotalMinor;
|
||||||
|
|
||||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
// Mixed-class parties: rebuild the floor per leg from each traveller's own seat class. Both
|
||||||
|
// legs must resolve, otherwise fall back to the single-class figure captured above.
|
||||||
|
let effectiveAuthoritativeTotalMinor = authoritativeTotalMinor;
|
||||||
|
if (!isPackageRoundTrip) {
|
||||||
|
const primaryNationalityForFloor = passengersData[0]?.nationality;
|
||||||
|
const [outboundFloor, returnFloor] = await Promise.all([
|
||||||
|
this.resolveMixedClassFloorMinor({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
bookedSeatClassId: dto.seatClassId,
|
||||||
|
seatIds: dto.passengers.map((p) => p.seatId).filter((id): id is string => !!id),
|
||||||
|
nationality: primaryNationalityForFloor,
|
||||||
|
segmentRoute: outboundSegmentRoute,
|
||||||
|
fullRoute: outboundFullRoute,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
}),
|
||||||
|
this.resolveMixedClassFloorMinor({
|
||||||
|
scheduleId: dto.returnScheduleId!,
|
||||||
|
bookedSeatClassId: returnSeatClassId,
|
||||||
|
seatIds: dto.passengers.map((p) => p.returnSeatId).filter((id): id is string => !!id),
|
||||||
|
nationality: primaryNationalityForFloor,
|
||||||
|
segmentRoute: returnSegmentRoute,
|
||||||
|
fullRoute: returnFullRoute,
|
||||||
|
originStationId: dto.returnOriginStationId,
|
||||||
|
destinationStationId: dto.returnDestinationStationId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (outboundFloor !== null || returnFloor !== null) {
|
||||||
|
// Whichever leg is single-class keeps its original per-leg subtotal.
|
||||||
|
effectiveAuthoritativeTotalMinor = Math.max(
|
||||||
|
0,
|
||||||
|
(outboundFloor ?? outboundTotalBase) + (returnFloor ?? returnTotalBase) - discountMinor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.assertTotalNotUnderAuthoritative(totalMinor, effectiveAuthoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||||
|
|
||||||
// Create or resolve guest passenger (same as one-way)
|
// Create or resolve guest passenger (same as one-way)
|
||||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||||
@@ -979,6 +1125,9 @@ export class GuestBookingService {
|
|||||||
returnSeatClassId,
|
returnSeatClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
|
// Staff entry points stamp their own source (e.g. BACKOFFICE_GROUP); guest and
|
||||||
|
// authenticated bookings leave it unset and keep the schema default of 'WEB'.
|
||||||
|
...(dto.bookingSource ? { source: dto.bookingSource } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: contact.contactEmail,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: contact.contactPhone,
|
contactPhone: contact.contactPhone,
|
||||||
@@ -1212,6 +1361,9 @@ export class GuestBookingService {
|
|||||||
leg2OriginStationId: dto.transitStationId,
|
leg2OriginStationId: dto.transitStationId,
|
||||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||||
leg2SeatClassId: leg2SeatClassId,
|
leg2SeatClassId: leg2SeatClassId,
|
||||||
|
// Staff entry points stamp their own source (e.g. BACKOFFICE_GROUP); guest and
|
||||||
|
// authenticated bookings leave it unset and keep the schema default of 'WEB'.
|
||||||
|
...(dto.bookingSource ? { source: dto.bookingSource } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: contact.contactEmail,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: contact.contactPhone,
|
contactPhone: contact.contactPhone,
|
||||||
@@ -1478,6 +1630,9 @@ export class GuestBookingService {
|
|||||||
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
||||||
returnLeg2SeatClassId: retL2ClassId,
|
returnLeg2SeatClassId: retL2ClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
|
// Staff entry points stamp their own source (e.g. BACKOFFICE_GROUP); guest and
|
||||||
|
// authenticated bookings leave it unset and keep the schema default of 'WEB'.
|
||||||
|
...(dto.bookingSource ? { source: dto.bookingSource } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: contact.contactEmail,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: contact.contactPhone,
|
contactPhone: contact.contactPhone,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { SmsClientService } from './sms-client.service';
|
|||||||
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||||
import { buildSeatSummary } from '../../common/utils/booking-sms.utils';
|
import { buildSeatSummary } from '../../common/utils/booking-sms.utils';
|
||||||
|
import {
|
||||||
|
BOOKING_SOURCE_BACKOFFICE_RESERVATION,
|
||||||
|
BOOKING_SOURCE_GROUP,
|
||||||
|
} from '../bookings/booking-source.constants';
|
||||||
|
|
||||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||||
|
|
||||||
@@ -494,10 +498,19 @@ export class NotificationsService {
|
|||||||
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
|
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
|
||||||
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
|
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
|
||||||
const source = (booking as any)?.source ?? payload.booking?.source;
|
const source = (booking as any)?.source ?? payload.booking?.source;
|
||||||
if (source === 'BACKOFFICE_RESERVATION') {
|
if (source === BOOKING_SOURCE_BACKOFFICE_RESERVATION) {
|
||||||
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
|
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A staff group booking has no self-service customer to notify. Its contact details are the
|
||||||
|
// first row of a staff-uploaded roster, and this template's `/booking/detail?ref=` deep link
|
||||||
|
// points at the passenger portal — which that traveller most likely has no account for. The
|
||||||
|
// booking is made and settled at the counter, so texting row 1 an "awaiting payment" portal
|
||||||
|
// link is noise at best and confusing at worst.
|
||||||
|
if (source === BOOKING_SOURCE_GROUP) {
|
||||||
|
this.logger.log(`Skipping generic booking.created notification for group booking ${ref}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const template = await this.prisma.notificationTemplate.findUnique({
|
const template = await this.prisma.notificationTemplate.findUnique({
|
||||||
where: { code: 'booking.created' },
|
where: { code: 'booking.created' },
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ export function selectCountedBlocks(
|
|||||||
const bySchedule = new Map<string, Map<string, CountedBlock>>();
|
const bySchedule = new Map<string, Map<string, CountedBlock>>();
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
if (schedule.status === 'CANCELLED') continue;
|
// DRAFT alongside CANCELLED: an unpublished trip has sold nothing, so a blocked seat on it
|
||||||
|
// represents no lost revenue and would inflate the report.
|
||||||
|
if (schedule.status === 'CANCELLED' || schedule.status === 'DRAFT') continue;
|
||||||
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
|
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { InjectDataSource } from "@nestjs/typeorm";
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus, TripStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule statuses that must not count towards operational reporting.
|
||||||
|
*
|
||||||
|
* These filters were written as `{ not: 'CANCELLED' }` — a deny-list of exactly one value, which
|
||||||
|
* silently admits any status added later. DRAFT is such a value: an unpublished trip has no
|
||||||
|
* passengers, no revenue and no operational reality, so counting it would dilute occupancy,
|
||||||
|
* utilisation and revenue-loss figures with trips that are still being planned.
|
||||||
|
*/
|
||||||
|
const NON_OPERATIONAL_SCHEDULE_STATUSES: TripStatus[] = [
|
||||||
|
TripStatus.CANCELLED,
|
||||||
|
TripStatus.DRAFT,
|
||||||
|
];
|
||||||
import {
|
import {
|
||||||
BlockedSeatRevenueLossReport,
|
BlockedSeatRevenueLossReport,
|
||||||
UNCATEGORIZED_REASON_CATEGORY,
|
UNCATEGORIZED_REASON_CATEGORY,
|
||||||
@@ -865,12 +878,12 @@ export class ReportsService {
|
|||||||
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
|
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
|
||||||
|
|
||||||
const upcomingCount = await this.prisma.trainSchedule.count({
|
const upcomingCount = await this.prisma.trainSchedule.count({
|
||||||
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
|
where: { departureAt: { gte: from, lte: to }, status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (upcomingCount === 0) {
|
if (upcomingCount === 0) {
|
||||||
const latest = await this.prisma.trainSchedule.findFirst({
|
const latest = await this.prisma.trainSchedule.findFirst({
|
||||||
where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } },
|
where: { departureAt: { lt: now }, status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES } },
|
||||||
orderBy: { departureAt: 'desc' },
|
orderBy: { departureAt: 'desc' },
|
||||||
select: { departureAt: true },
|
select: { departureAt: true },
|
||||||
});
|
});
|
||||||
@@ -882,7 +895,7 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
|
where: { departureAt: { gte: from, lte: to }, status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
departureAt: true,
|
departureAt: true,
|
||||||
@@ -1209,12 +1222,12 @@ export class ReportsService {
|
|||||||
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
|
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
|
||||||
|
|
||||||
const upcomingCount = await this.prisma.trainSchedule.count({
|
const upcomingCount = await this.prisma.trainSchedule.count({
|
||||||
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
|
where: { departureAt: { gte: from, lte: to }, status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (upcomingCount === 0) {
|
if (upcomingCount === 0) {
|
||||||
const latest = await this.prisma.trainSchedule.findFirst({
|
const latest = await this.prisma.trainSchedule.findFirst({
|
||||||
where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } },
|
where: { departureAt: { lt: now }, status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES } },
|
||||||
orderBy: { departureAt: 'desc' },
|
orderBy: { departureAt: 'desc' },
|
||||||
select: { departureAt: true },
|
select: { departureAt: true },
|
||||||
});
|
});
|
||||||
@@ -1226,7 +1239,7 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
|
where: { departureAt: { gte: from, lte: to }, status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
departureAt: true,
|
departureAt: true,
|
||||||
@@ -2344,7 +2357,7 @@ export class ReportsService {
|
|||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
departureAt: { gte: dateFrom, lte: dateTo },
|
departureAt: { gte: dateFrom, lte: dateTo },
|
||||||
status: { not: 'CANCELLED' },
|
status: { notIn: NON_OPERATIONAL_SCHEDULE_STATUSES },
|
||||||
...(query.scheduleId ? { id: query.scheduleId } : {}),
|
...(query.scheduleId ? { id: query.scheduleId } : {}),
|
||||||
...(query.routeId ? { routeId: query.routeId } : {}),
|
...(query.routeId ? { routeId: query.routeId } : {}),
|
||||||
...(query.trainId ? { trainId: query.trainId } : {}),
|
...(query.trainId ? { trainId: query.trainId } : {}),
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export class RoutesService {
|
|||||||
// Propagate new stop timing to all future schedules on this route so that
|
// Propagate new stop timing to all future schedules on this route so that
|
||||||
// per-stop check-in cutoffs reflect the updated travelMinutesToStop values.
|
// per-stop check-in cutoffs reflect the updated travelMinutesToStop values.
|
||||||
const futureSchedules = await this.prisma.trainSchedule.findMany({
|
const futureSchedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } },
|
// DRAFT is included: an unpublished schedule is by definition still in the future and
|
||||||
|
// will be published later, so it needs the new stop timing just as much as a live one.
|
||||||
|
// Omitting it would publish a trip carrying stop times computed from the OLD route.
|
||||||
|
where: { routeId: id, status: { in: ['DRAFT', 'SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } },
|
||||||
select: { id: true, departureAt: true, arrivalAt: true },
|
select: { id: true, departureAt: true, arrivalAt: true },
|
||||||
});
|
});
|
||||||
const stopsForTiming = dto.stops
|
const stopsForTiming = dto.stops
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { assertScheduleTransitionAllowed, isPublishTransition } from './schedule-transition.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DRAFT edges are the only ones this guard constrains. Everything that was legal before it
|
||||||
|
* existed must stay legal, so the "unchanged" cases below are as important as the new rules.
|
||||||
|
*/
|
||||||
|
describe('assertScheduleTransitionAllowed', () => {
|
||||||
|
const bookable = { coachAssignmentCount: 2, activeBookingCount: 0 };
|
||||||
|
const publishable = { ...bookable, viaPublishRoute: true };
|
||||||
|
|
||||||
|
describe('publishing a draft', () => {
|
||||||
|
it('allows DRAFT -> SCHEDULED through the publish route', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('DRAFT', 'SCHEDULED', publishable)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses DRAFT -> SCHEDULED through the generic status routes', () => {
|
||||||
|
// Those routes require only schedules:edit; publishing requires schedules:manage. If this
|
||||||
|
// were allowed the permission gate on POST /schedules/:id/publish would be decorative.
|
||||||
|
expect(() => assertScheduleTransitionAllowed('DRAFT', 'SCHEDULED', bookable)).toThrow(
|
||||||
|
/POST \/schedules\/:id\/publish/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to publish a schedule with no coaches — it would be listed but unbookable', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertScheduleTransitionAllowed('DRAFT', 'SCHEDULED', {
|
||||||
|
coachAssignmentCount: 0,
|
||||||
|
activeBookingCount: 0,
|
||||||
|
viaPublishRoute: true,
|
||||||
|
}),
|
||||||
|
).toThrow(/no coaches assigned/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a draft to be cancelled outright', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('DRAFT', 'CANCELLED', bookable)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to jump a draft straight to an operational status', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('DRAFT', 'BOARDING', bookable)).toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(() => assertScheduleTransitionAllowed('DRAFT', 'ARRIVED', bookable)).toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('un-publishing', () => {
|
||||||
|
it('allows SCHEDULED -> DRAFT while nothing is booked', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('SCHEDULED', 'DRAFT', bookable)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses SCHEDULED -> DRAFT once passengers hold seats', () => {
|
||||||
|
// Withdrawing the trip would leave those bookings holding seats and a payment deadline on
|
||||||
|
// a trip that has vanished from search, reschedule and upgrade.
|
||||||
|
expect(() =>
|
||||||
|
assertScheduleTransitionAllowed('SCHEDULED', 'DRAFT', {
|
||||||
|
coachAssignmentCount: 2,
|
||||||
|
activeBookingCount: 3,
|
||||||
|
}),
|
||||||
|
).toThrow(/3 active bookings/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to move a cancelled or arrived trip back to DRAFT', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('CANCELLED', 'DRAFT', bookable)).toThrow();
|
||||||
|
expect(() => assertScheduleTransitionAllowed('ARRIVED', 'DRAFT', bookable)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal statuses', () => {
|
||||||
|
it('treats CANCELLED as final', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('CANCELLED', 'SCHEDULED', bookable)).toThrow(
|
||||||
|
/Cancelling is final/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats ARRIVED as final', () => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed('ARRIVED', 'SCHEDULED', bookable)).toThrow(
|
||||||
|
/already arrived/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('existing operational transitions are untouched', () => {
|
||||||
|
// These are the hops TasksService.syncScheduleStatuses drives every minute, plus the manual
|
||||||
|
// corrections an `schedules:edit` holder could always make. None may start throwing.
|
||||||
|
it.each([
|
||||||
|
['SCHEDULED', 'BOARDING'],
|
||||||
|
['BOARDING', 'EN_ROUTE'],
|
||||||
|
['EN_ROUTE', 'ARRIVED'],
|
||||||
|
['SCHEDULED', 'CANCELLED'],
|
||||||
|
['SCHEDULED', 'DELAYED'],
|
||||||
|
['DELAYED', 'SCHEDULED'],
|
||||||
|
['BOARDING', 'SCHEDULED'],
|
||||||
|
])('%s -> %s stays allowed', (from, to) => {
|
||||||
|
expect(() => assertScheduleTransitionAllowed(from, to, bookable)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the status has not changed', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertScheduleTransitionAllowed('CANCELLED', 'CANCELLED', bookable),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPublishTransition', () => {
|
||||||
|
it('is true only for DRAFT -> SCHEDULED', () => {
|
||||||
|
expect(isPublishTransition('DRAFT', 'SCHEDULED')).toBe(true);
|
||||||
|
expect(isPublishTransition('DELAYED', 'SCHEDULED')).toBe(false);
|
||||||
|
expect(isPublishTransition('DRAFT', 'CANCELLED')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first status-transition guard in the schedules module.
|
||||||
|
*
|
||||||
|
* Until DRAFT existed there was nothing to guard: every `TripStatus` was an operational
|
||||||
|
* display value that the cron drove forward on a timer, so `updateScheduleStatus` and
|
||||||
|
* `updateSchedulePartial` both wrote whatever the DTO carried, from any prior status. DRAFT is
|
||||||
|
* different — it is the only status that decides whether the public can book the trip at all,
|
||||||
|
* so the DRAFT edges have to be real transitions rather than a free-form field write.
|
||||||
|
*
|
||||||
|
* Deliberately narrow. Everything that was legal before is still legal; this only constrains
|
||||||
|
* the edges that involve DRAFT, so no existing operational flow (including the cron's
|
||||||
|
* SCHEDULED -> BOARDING -> EN_ROUTE -> ARRIVED chain) changes behaviour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Statuses the every-minute cron in TasksService drives. It never touches DRAFT. */
|
||||||
|
const OPERATIONAL: readonly string[] = ['SCHEDULED', 'BOARDING', 'EN_ROUTE', 'ARRIVED', 'DELAYED'];
|
||||||
|
|
||||||
|
export interface ScheduleTransitionContext {
|
||||||
|
/** Coach assignments currently on the schedule — publishing with none yields an unbookable trip. */
|
||||||
|
coachAssignmentCount: number;
|
||||||
|
/** Bookings on the schedule that still hold seats (anything but CANCELLED/REFUNDED). */
|
||||||
|
activeBookingCount: number;
|
||||||
|
/**
|
||||||
|
* True only when called from `publishSchedule` (POST /schedules/:id/publish), the route that
|
||||||
|
* actually enforces `schedules:manage`. The generic status routes require only
|
||||||
|
* `schedules:edit`, so they must not be able to publish.
|
||||||
|
*/
|
||||||
|
viaPublishRoute?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws when `from -> to` is not a legal transition.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - `DRAFT -> SCHEDULED` (publish) requires at least one coach, mirroring the same guard
|
||||||
|
* `createSchedule` already applies ("A schedule must have at least one coach assigned to be
|
||||||
|
* bookable"). Publishing a coachless trip would put a trip on sale that search itself filters
|
||||||
|
* out via `coachAssignments: { some: {} }` — visible in listings, unbookable in practice.
|
||||||
|
* - `SCHEDULED -> DRAFT` (un-publish) is refused once any active booking exists. Withdrawing a
|
||||||
|
* trip the public has already bought seats on would strand those bookings: they would keep
|
||||||
|
* their seats and their payment deadline while the trip vanished from search, reschedule and
|
||||||
|
* upgrade (all of which require a non-DRAFT status).
|
||||||
|
* - `CANCELLED` and `ARRIVED` stay terminal, as they already were in practice.
|
||||||
|
* - Anything else among the operational statuses is left exactly as permissive as before.
|
||||||
|
*/
|
||||||
|
export function assertScheduleTransitionAllowed(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
ctx: ScheduleTransitionContext,
|
||||||
|
): void {
|
||||||
|
if (from === to) return;
|
||||||
|
|
||||||
|
if (from === 'CANCELLED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This trip is cancelled. Cancelling is final — create a new schedule instead of reviving this one.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from === 'ARRIVED') {
|
||||||
|
throw new BadRequestException('This trip has already arrived; its status can no longer be changed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from === 'DRAFT') {
|
||||||
|
if (to === 'SCHEDULED') {
|
||||||
|
if (ctx.coachAssignmentCount < 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot publish a schedule with no coaches assigned — it would be visible but unbookable. ' +
|
||||||
|
'Assign at least one coach first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ctx.viaPublishRoute) {
|
||||||
|
// Publishing needs `schedules:manage`, which the generic status routes do not require.
|
||||||
|
// Rejecting it here is what stops POST /schedules/:id/publish's permission gate from
|
||||||
|
// being sidestepped by PATCH /schedules/:id or PATCH /schedules/:id/status.
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Use POST /schedules/:id/publish to publish a draft schedule. Publishing puts the trip ' +
|
||||||
|
'on public sale and requires the schedules:manage permission.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (to === 'CANCELLED') return;
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A draft schedule can only be published (SCHEDULED) or cancelled, not moved to ${to}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to === 'DRAFT') {
|
||||||
|
if (!OPERATIONAL.includes(from)) {
|
||||||
|
throw new BadRequestException(`Cannot move a schedule from ${from} back to DRAFT.`);
|
||||||
|
}
|
||||||
|
if (ctx.activeBookingCount > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot un-publish this schedule — it already has ${ctx.activeBookingCount} active ` +
|
||||||
|
`booking${ctx.activeBookingCount === 1 ? '' : 's'}. Withdrawing it would hide a trip ` +
|
||||||
|
'passengers have already booked. Cancel the trip instead if it is not running.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operational -> operational: unchanged, as permissive as it has always been.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this transition makes a previously non-public schedule publicly bookable. */
|
||||||
|
export function isPublishTransition(from: string, to: string): boolean {
|
||||||
|
return from === 'DRAFT' && to === 'SCHEDULED';
|
||||||
|
}
|
||||||
@@ -47,6 +47,11 @@ describe('SchedulesService — audit', () => {
|
|||||||
delete: jest.fn().mockResolvedValue({}),
|
delete: jest.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
segmentFareRule: { findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() },
|
segmentFareRule: { findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() },
|
||||||
|
// Read by loadTransitionContext whenever a status actually changes, so the transition
|
||||||
|
// guard can judge a DRAFT edge (publishable? un-publishable?). One coach and no bookings
|
||||||
|
// is the permissive default; the transition tests override it where it matters.
|
||||||
|
coachAssignment: { count: jest.fn().mockResolvedValue(1) },
|
||||||
|
booking: { count: jest.fn().mockResolvedValue(0) },
|
||||||
};
|
};
|
||||||
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,17 @@ export class SchedulesController {
|
|||||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@IsPublic()
|
@PassengerStaff([S.view, S.manage, P.admin]) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
@ApiOperation({
|
||||||
|
summary: 'List schedules with optional filters (staff)',
|
||||||
|
description:
|
||||||
|
'Backoffice listing — returns schedules in every status, including unpublished DRAFT ones. ' +
|
||||||
|
'Requires `schedules:view`.\n\n' +
|
||||||
|
'Previously public with no status filter, which would have exposed DRAFT trips to anyone. ' +
|
||||||
|
'The passenger portal never called this route (it uses `POST /search`), so requiring ' +
|
||||||
|
'authentication costs nothing; only backoffice pages consume it.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: 'Missing `schedules:view`' })
|
||||||
@ApiQuery({ name: 'date', required: false })
|
@ApiQuery({ name: 'date', required: false })
|
||||||
@ApiQuery({ name: 'routeId', required: false })
|
@ApiQuery({ name: 'routeId', required: false })
|
||||||
@ApiQuery({ name: 'trainId', required: false })
|
@ApiQuery({ name: 'trainId', required: false })
|
||||||
@@ -155,29 +164,61 @@ export class SchedulesController {
|
|||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update schedule status',
|
summary: 'Update schedule status (incl. publishing a DRAFT)',
|
||||||
description:
|
description:
|
||||||
'Routine transitions need `schedules:edit`. Moving a schedule to CANCELLED additionally ' +
|
'Routine transitions need `schedules:edit`. Two need more:\n\n' +
|
||||||
'needs `schedules:cancel` — cancelling strands every booked passenger, so it is a separate ' +
|
'- **CANCELLED** additionally needs `schedules:cancel` — cancelling strands every booked ' +
|
||||||
'grant from editing a timetable.',
|
'passenger, so it is a separate grant from editing a timetable.\n' +
|
||||||
|
'- **DRAFT → SCHEDULED** (publish) additionally needs `schedules:manage` — publishing puts ' +
|
||||||
|
'a trip on public sale, which is a bigger step than retiming one. It also requires at ' +
|
||||||
|
'least one coach to be assigned, or the trip would be listed but unbookable.\n\n' +
|
||||||
|
'DRAFT is only reachable from an operational status, and only while the schedule has no ' +
|
||||||
|
'active bookings.',
|
||||||
})
|
})
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 403, description: 'Cancelling without `schedules:cancel`' })
|
@ApiResponse({ status: 400, description: 'Illegal transition, or publishing with no coaches assigned' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Cancelling without `schedules:cancel`, or publishing without `schedules:manage`' })
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateScheduleStatusDto,
|
@Body() dto: UpdateScheduleStatusDto,
|
||||||
@Req() req: { user?: unknown },
|
@Req() req: { user?: unknown },
|
||||||
) {
|
) {
|
||||||
// The guard cannot see the body — CANCELLED arrives on the same route as
|
// The guard cannot see the body — CANCELLED and SCHEDULED arrive on the same route as
|
||||||
// BOARDING or DELAYED — so the narrower check happens here. `schedules.manage`
|
// BOARDING or DELAYED — so the narrower checks happen here. `schedules.manage`
|
||||||
// is in the list, so a manage holder cancels exactly as they do today; an
|
// is in both lists, so a manage holder behaves exactly as they do today; an
|
||||||
// `edit`-only holder can retime a trip but not cancel it.
|
// `edit`-only holder can retime a trip but neither cancel nor publish it.
|
||||||
if (dto.status === TripStatus.CANCELLED) {
|
if (dto.status === TripStatus.CANCELLED) {
|
||||||
assertAnyPassengerPermission(req.user as never, [S.cancel, S.manage, P.admin]);
|
assertAnyPassengerPermission(req.user as never, [S.cancel, S.manage, P.admin]);
|
||||||
}
|
}
|
||||||
|
// NOTE: publishing (DRAFT -> SCHEDULED) is deliberately NOT handled here. Gating it on
|
||||||
|
// "status === SCHEDULED" would also catch DELAYED -> SCHEDULED and BOARDING -> SCHEDULED,
|
||||||
|
// which `schedules:edit` holders may do today — silently taking away an existing ability.
|
||||||
|
// Publish has its own route below; the transition guard rejects the DRAFT edge on this one.
|
||||||
return this.service.updateScheduleStatus(id, dto);
|
return this.service.updateScheduleStatus(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/publish')
|
||||||
|
@PassengerWrite(S.manage, P.admin) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Publish a DRAFT schedule — puts the trip on public sale',
|
||||||
|
description:
|
||||||
|
'Moves the schedule DRAFT → SCHEDULED. From that moment the public search channel returns ' +
|
||||||
|
'it and any seat still free can be booked normally.\n\n' +
|
||||||
|
'Seats already taken by group bookings made while the trip was a draft are unaffected: they ' +
|
||||||
|
'are held by the same SeatHold/JourneySegment rows the public flow reads, so publishing ' +
|
||||||
|
'changes no seat state at all and cannot reassign or double-sell a seat.\n\n' +
|
||||||
|
'Requires `schedules:manage` — a separate grant from `schedules:edit`, because publishing ' +
|
||||||
|
'exposes a trip to customers rather than merely retiming it. The schedule must have at ' +
|
||||||
|
'least one coach assigned.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Schedule published; now publicly bookable' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Not a DRAFT schedule, or no coaches assigned' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Missing `schedules:manage`' })
|
||||||
|
publishSchedule(@Param('id') id: string) {
|
||||||
|
return this.service.publishSchedule(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerDelete(S.delete)
|
@PassengerDelete(S.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNes
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-maintained mirror of the Prisma `TripStatus` enum (prisma/schema.prisma) — it is NOT
|
||||||
|
* derived from it. `@IsEnum(TripStatus)` validates against THIS list, so a value added to the
|
||||||
|
* Prisma enum without being added here is rejected by the API even though the DB accepts it.
|
||||||
|
* Keep the two in sync.
|
||||||
|
*/
|
||||||
export enum TripStatus {
|
export enum TripStatus {
|
||||||
|
DRAFT = 'DRAFT',
|
||||||
SCHEDULED = 'SCHEDULED',
|
SCHEDULED = 'SCHEDULED',
|
||||||
BOARDING = 'BOARDING',
|
BOARDING = 'BOARDING',
|
||||||
EN_ROUTE = 'EN_ROUTE',
|
EN_ROUTE = 'EN_ROUTE',
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
|||||||
import { snapshot } from '../../common/audit-snapshot';
|
import { snapshot } from '../../common/audit-snapshot';
|
||||||
import { LiveService } from '../live/live.service';
|
import { LiveService } from '../live/live.service';
|
||||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||||
|
import {
|
||||||
|
assertScheduleTransitionAllowed,
|
||||||
|
ScheduleTransitionContext,
|
||||||
|
} from './schedule-transition.util';
|
||||||
|
|
||||||
const SCHEDULE_AUDIT_FIELDS = [
|
const SCHEDULE_AUDIT_FIELDS = [
|
||||||
'trainId',
|
'trainId',
|
||||||
@@ -437,10 +441,76 @@ export class SchedulesService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts what `assertScheduleTransitionAllowed` needs to judge a DRAFT edge: coaches (can this
|
||||||
|
* be published?) and seat-holding bookings (can this be un-published?). Only loaded when the
|
||||||
|
* status actually changes, so ordinary operational transitions pay nothing for it.
|
||||||
|
*/
|
||||||
|
private async loadTransitionContext(scheduleId: string): Promise<ScheduleTransitionContext> {
|
||||||
|
const [coachAssignmentCount, activeBookingCount] = await Promise.all([
|
||||||
|
this.prisma.coachAssignment.count({ where: { scheduleId } }),
|
||||||
|
this.prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
status: { notIn: ['CANCELLED', 'REFUNDED'] },
|
||||||
|
OR: [{ scheduleId }, { returnScheduleId: scheduleId }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { coachAssignmentCount, activeBookingCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DRAFT -> SCHEDULED. The one transition that makes a trip publicly bookable.
|
||||||
|
*
|
||||||
|
* Nothing about seat inventory changes here, and that is the whole point: seats taken by group
|
||||||
|
* bookings made against the draft are held by the same `SeatHold` / `JourneySegment` rows that
|
||||||
|
* the public availability check reads (SegmentsService.getSeatAvailabilityMap). Publishing
|
||||||
|
* flips one column on the schedule, so those seats simply continue to read as unavailable —
|
||||||
|
* there is no re-seating step in which a group's allocation could be disturbed.
|
||||||
|
*/
|
||||||
|
async publishSchedule(id: string) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
if (schedule.status !== 'DRAFT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only a DRAFT schedule can be published; this one is ${schedule.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertScheduleTransitionAllowed(schedule.status, 'SCHEDULED', {
|
||||||
|
...(await this.loadTransitionContext(id)),
|
||||||
|
viaPublishRoute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.prisma.trainSchedule.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'SCHEDULED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.auditService.log({
|
||||||
|
action: AUDIT_ACTIONS.STATUS_CHANGE,
|
||||||
|
entityType: AUDIT_ENTITIES.Schedule,
|
||||||
|
entityId: id,
|
||||||
|
oldData: { status: schedule.status },
|
||||||
|
newData: { status: updated.status, departureAt: updated.departureAt.toISOString(), published: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
if (dto.status !== schedule.status) {
|
||||||
|
assertScheduleTransitionAllowed(
|
||||||
|
schedule.status,
|
||||||
|
dto.status,
|
||||||
|
await this.loadTransitionContext(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.trainSchedule.update({
|
const updated = await this.prisma.trainSchedule.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: dto.status },
|
data: { status: dto.status },
|
||||||
@@ -1000,7 +1070,19 @@ export class SchedulesService {
|
|||||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.status) updateData.status = dto.status;
|
if (dto.status) {
|
||||||
|
// Same guard as the dedicated PATCH /:id/status route. The backoffice edit modal sends
|
||||||
|
// status through THIS path, not that one, so a check on only one of the two would be
|
||||||
|
// trivially bypassable from the UI that operators actually use.
|
||||||
|
if (dto.status !== schedule.status) {
|
||||||
|
assertScheduleTransitionAllowed(
|
||||||
|
schedule.status,
|
||||||
|
dto.status,
|
||||||
|
await this.loadTransitionContext(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateData.status = dto.status;
|
||||||
|
}
|
||||||
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
|
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
|
||||||
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
|
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { SearchService } from './search.service';
|
||||||
|
import { SearchTripsDto } from './search.dto';
|
||||||
|
import { PassengerWrite } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff group-booking search channel.
|
||||||
|
*
|
||||||
|
* Deliberately a SEPARATE controller from `SearchController` rather than another route on it.
|
||||||
|
* `SearchController` carries a class-level `@IsPublic()`, and `JwtGuard` resolves that key with
|
||||||
|
* `getAllAndOverride([handler, class])` — a handler with no key of its own inherits the class's
|
||||||
|
* `true` and stays public. Worse, `JwtGuard` returns early for a public route and never
|
||||||
|
* populates `request.user`, so an in-handler permission check there is not merely awkward, it is
|
||||||
|
* impossible: there is no user to check.
|
||||||
|
*
|
||||||
|
* Splitting the controller is what makes the guard real. The channel is therefore decided by
|
||||||
|
* which route the caller can reach, not by a field they send — which matters because this
|
||||||
|
* channel exposes unpublished DRAFT schedules.
|
||||||
|
*/
|
||||||
|
@ApiTags('Search')
|
||||||
|
@Controller('search')
|
||||||
|
export class GroupSearchController {
|
||||||
|
constructor(private service: SearchService) {}
|
||||||
|
|
||||||
|
@Post('group')
|
||||||
|
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search trips as staff for a group booking — includes unpublished DRAFT trips',
|
||||||
|
description: `Same request body and response shape as the public \`POST /search\`, but run on the staff channel.
|
||||||
|
|
||||||
|
The staff channel is a strict **superset** of the public one:
|
||||||
|
|
||||||
|
| Schedule state | Public \`POST /search\` | This route |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| \`DRAFT\` (not yet published) | hidden | **visible** |
|
||||||
|
| \`SCHEDULED\`/\`BOARDING\`/\`EN_ROUTE\`, ordinary | visible | visible |
|
||||||
|
| \`SCHEDULED\`/\`BOARDING\`/\`EN_ROUTE\`, \`isGroupBookingOnly\` | hidden | **visible** |
|
||||||
|
| \`CANCELLED\`/\`ARRIVED\`/\`DELAYED\` | hidden | hidden |
|
||||||
|
| \`isPackageOnly\` | hidden | hidden |
|
||||||
|
|
||||||
|
So a group can be booked onto an ordinary scheduled service, onto a trip reserved for groups, or onto a draft trip that has not gone on sale yet — all through the same seat inventory as normal booking.
|
||||||
|
|
||||||
|
Replaces the old \`channel: 'GROUP_BOOKING'\` body field on \`POST /search\`, which was unguarded.`,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Matching schedules, including DRAFT and group-reserved ones' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Caller lacks bookings:create / bookings:manage' })
|
||||||
|
searchTripsForGroup(@Body() dto: SearchTripsDto) {
|
||||||
|
return this.service.searchTrips(dto, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
PUBLIC_BOOKABLE_STATUSES,
|
||||||
|
STAFF_BOOKABLE_STATUSES,
|
||||||
|
scheduleVisibilityWhere,
|
||||||
|
} from './search.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visibility partition is the whole of requirement 1: a group must be bookable onto an
|
||||||
|
* ordinary scheduled trip, while a DRAFT trip must stay invisible to the public.
|
||||||
|
*
|
||||||
|
* These assert the WHERE clause rather than hitting the database, because the clause is the
|
||||||
|
* single place both properties are decided — it is spread into all four schedule queries in
|
||||||
|
* SearchService (direct, alternatives, transit leg 1, transit leg 2).
|
||||||
|
*/
|
||||||
|
describe('scheduleVisibilityWhere', () => {
|
||||||
|
describe('public channel', () => {
|
||||||
|
const where = scheduleVisibilityWhere(false);
|
||||||
|
|
||||||
|
it('excludes DRAFT', () => {
|
||||||
|
expect(where.status).toEqual({ in: [...PUBLIC_BOOKABLE_STATUSES] });
|
||||||
|
expect((where.status as { in: string[] }).in).not.toContain('DRAFT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes group-reserved and package-only trips', () => {
|
||||||
|
expect(where.isGroupBookingOnly).toBe(false);
|
||||||
|
expect(where.isPackageOnly).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('staff group-booking channel', () => {
|
||||||
|
const where = scheduleVisibilityWhere(true);
|
||||||
|
|
||||||
|
it('includes DRAFT so a party can be assembled before the trip goes on sale', () => {
|
||||||
|
expect((where.status as { in: string[] }).in).toContain('DRAFT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a strict superset of the public statuses', () => {
|
||||||
|
const staff = (where.status as { in: string[] }).in;
|
||||||
|
for (const status of PUBLIC_BOOKABLE_STATUSES) {
|
||||||
|
expect(staff).toContain(status);
|
||||||
|
}
|
||||||
|
expect(staff).toEqual([...STAFF_BOOKABLE_STATUSES]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT constrain isGroupBookingOnly — this is what lets a group book an ordinary trip', () => {
|
||||||
|
// The old behaviour was `isGroupBookingOnly: forGroupBooking`, an exclusive partition that
|
||||||
|
// made staff see ONLY group-flagged trips. Requirement 1 is exactly the removal of that.
|
||||||
|
expect(where.isGroupBookingOnly).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still excludes package-only trips, which belong to the tourism flow', () => {
|
||||||
|
expect(where.isPackageOnly).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-bookable statuses are hidden from both channels', () => {
|
||||||
|
it.each(['CANCELLED', 'ARRIVED', 'DELAYED'])('%s', (status) => {
|
||||||
|
expect((scheduleVisibilityWhere(false).status as { in: string[] }).in).not.toContain(status);
|
||||||
|
expect((scheduleVisibilityWhere(true).status as { in: string[] }).in).not.toContain(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,12 +28,14 @@ export class SearchTripsDto {
|
|||||||
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
|
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
|
||||||
@IsOptional() @IsDateString() returnDate?: string;
|
@IsOptional() @IsDateString() returnDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
// NOTE: there is deliberately no `channel` field.
|
||||||
example: 'PORTAL',
|
//
|
||||||
enum: ['PORTAL', 'GROUP_BOOKING'],
|
// It used to live here, which meant the calling surface was chosen by the request body on a
|
||||||
description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.',
|
// route that is `@IsPublic()` — anyone could send `channel: 'GROUP_BOOKING'`. That was
|
||||||
})
|
// harmless while the staff channel only revealed group-reserved trips, but it is not once
|
||||||
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
|
// that channel also reveals unpublished DRAFT ones. The channel is now decided by WHICH
|
||||||
|
// ROUTE you can reach: public `POST /search` is always PORTAL, and `POST /search/group` is
|
||||||
|
// permission-guarded. See SearchController.
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AvailableDatesQueryDto {
|
export class AvailableDatesQueryDto {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SearchController } from './search.controller';
|
import { SearchController } from './search.controller';
|
||||||
|
import { GroupSearchController } from './group-search.controller';
|
||||||
import { SearchService } from './search.service';
|
import { SearchService } from './search.service';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
@@ -7,7 +8,9 @@ import { SegmentsModule } from '../segments/segments.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||||
controllers: [SearchController],
|
// GroupSearchController is registered BEFORE SearchController so `POST /search/group` is
|
||||||
|
// matched by its own guarded handler rather than being swallowed by a broader public route.
|
||||||
|
controllers: [GroupSearchController, SearchController],
|
||||||
providers: [SearchService],
|
providers: [SearchService],
|
||||||
exports: [SearchService],
|
exports: [SearchService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
|||||||
import { SegmentsService } from "../segments/segments.service";
|
import { SegmentsService } from "../segments/segments.service";
|
||||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||||
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||||
import { Currency, Prisma } from "@prisma/client";
|
import { Currency, Prisma, TripStatus } from "@prisma/client";
|
||||||
import { Passenger } from "@edr/types";
|
import { Passenger } from "@edr/types";
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
@@ -72,6 +72,52 @@ const SCHEDULE_INCLUDE = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses a schedule may be booked in from the PUBLIC channel.
|
||||||
|
*
|
||||||
|
* BOARDING and EN_ROUTE are included on purpose: they are operational display statuses the
|
||||||
|
* schedule-level cron sets on a fixed timer (tasks.service.ts), NOT booking-closed signals.
|
||||||
|
* The real booking cutoff is per-stop and configurable (RouteStop/Route.checkinMinutesBefore),
|
||||||
|
* enforced by buildScheduleResult against each stop's own estimated arrival/departure.
|
||||||
|
* Excluding them here would impose a hidden, non-configurable 30-minute cutoff on top.
|
||||||
|
*/
|
||||||
|
export const PUBLIC_BOOKABLE_STATUSES = ["SCHEDULED", "BOARDING", "EN_ROUTE"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses the STAFF group-booking channel may book in — the public set plus DRAFT, so staff
|
||||||
|
* can assemble a party on an unpublished trip before it goes on sale.
|
||||||
|
*/
|
||||||
|
export const STAFF_BOOKABLE_STATUSES = ["DRAFT", ...PUBLIC_BOOKABLE_STATUSES] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of truth for "which schedules may this channel see".
|
||||||
|
*
|
||||||
|
* `status` and `isGroupBookingOnly` are ORTHOGONAL, and the staff channel is a strict SUPERSET
|
||||||
|
* of the public one — not a mirror partition, which is what it used to be:
|
||||||
|
*
|
||||||
|
* status isGroupBookingOnly public staff
|
||||||
|
* DRAFT any no yes
|
||||||
|
* SCHEDULED/BOARDING/EN_ROUTE false yes yes <- group on a normal trip
|
||||||
|
* SCHEDULED/BOARDING/EN_ROUTE true no yes
|
||||||
|
* CANCELLED/ARRIVED/DELAYED any no no
|
||||||
|
*
|
||||||
|
* `status` means "is this trip ready to sell"; `isGroupBookingOnly` means "this trip is
|
||||||
|
* reserved for a group, keep it off the portal". Staff may book any trip that is bookable at
|
||||||
|
* all, which is what lets a group be added to an ordinary scheduled service.
|
||||||
|
*/
|
||||||
|
export function scheduleVisibilityWhere(forGroupBooking: boolean): Prisma.TrainScheduleWhereInput {
|
||||||
|
return forGroupBooking
|
||||||
|
? {
|
||||||
|
status: { in: [...STAFF_BOOKABLE_STATUSES] as TripStatus[] },
|
||||||
|
isPackageOnly: false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: { in: [...PUBLIC_BOOKABLE_STATUSES] as TripStatus[] },
|
||||||
|
isPackageOnly: false,
|
||||||
|
isGroupBookingOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -81,11 +127,16 @@ export class SearchService {
|
|||||||
private segmentsService: SegmentsService,
|
private segmentsService: SegmentsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchTrips(dto: SearchTripsDto) {
|
/**
|
||||||
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
|
* @param forGroupBooking the staff bulk-booking channel. A strict SUPERSET of the public one
|
||||||
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
|
* (see `scheduleVisibilityWhere`): staff see every bookable trip — ordinary, group-reserved,
|
||||||
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
|
* and unpublished DRAFT — so a group can be added to an ordinary scheduled service.
|
||||||
const forGroupBooking = dto.channel === "GROUP_BOOKING";
|
*
|
||||||
|
* This is an ARGUMENT, not a body field, on purpose. It used to be `dto.channel`, which let
|
||||||
|
* any caller of the public `POST /search` opt into the staff view. It is now set only by
|
||||||
|
* `POST /search/group`, which is permission-guarded.
|
||||||
|
*/
|
||||||
|
async searchTrips(dto: SearchTripsDto, forGroupBooking = false) {
|
||||||
const [direct, transit] = await Promise.all([
|
const [direct, transit] = await Promise.all([
|
||||||
this.searchSchedules(
|
this.searchSchedules(
|
||||||
dto.originStationId,
|
dto.originStationId,
|
||||||
@@ -244,19 +295,8 @@ export class SearchService {
|
|||||||
const totalPassengers = adultCount + (childCount ?? 0);
|
const totalPassengers = adultCount + (childCount ?? 0);
|
||||||
const NEEDED = 3;
|
const NEEDED = 3;
|
||||||
|
|
||||||
// Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the
|
|
||||||
// schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) —
|
|
||||||
// it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable
|
|
||||||
// (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live
|
|
||||||
// check against each stop's estimated arrival/departure. Excluding BOARDING here would
|
|
||||||
// silently impose a hidden, non-configurable 30-minute cutoff on top of that.
|
|
||||||
const baseWhere: Prisma.TrainScheduleWhereInput = {
|
const baseWhere: Prisma.TrainScheduleWhereInput = {
|
||||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
...scheduleVisibilityWhere(forGroupBooking),
|
||||||
isPackageOnly: false,
|
|
||||||
// Group Booking's search is exclusive, not additive: staff only ever see schedules
|
|
||||||
// explicitly created for group booking, never the normal passenger-facing ones, and the
|
|
||||||
// portal never sees group-only ones. Each channel is a strict partition of the other.
|
|
||||||
isGroupBookingOnly: forGroupBooking,
|
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
coachAssignments: { some: {} },
|
coachAssignments: { some: {} },
|
||||||
};
|
};
|
||||||
@@ -342,12 +382,7 @@ export class SearchService {
|
|||||||
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
|
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
// EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
|
...scheduleVisibilityWhere(forGroupBooking),
|
||||||
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
|
|
||||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
|
||||||
isPackageOnly: false,
|
|
||||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
|
||||||
isGroupBookingOnly: forGroupBooking,
|
|
||||||
departureAt: { gte: date, lt: nextDay },
|
departureAt: { gte: date, lt: nextDay },
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
coachAssignments: { some: {} },
|
coachAssignments: { some: {} },
|
||||||
@@ -438,11 +473,14 @@ export class SearchService {
|
|||||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||||
if (sameDayForPair.every((s) => s.isPackageOnly))
|
if (sameDayForPair.every((s) => s.isPackageOnly))
|
||||||
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
||||||
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
|
// Public channel only: every trip that day is withheld from the portal — either reserved
|
||||||
// code covers both directions: the portal finding only group-reserved schedules, and Group
|
// for a group or not yet published. The staff channel is a superset and cannot reach this
|
||||||
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
|
// case, so the branch is skipped for it (a staff search that found nothing bookable fell
|
||||||
// the right copy per caller.
|
// through on status or coaches, which the codes below cover).
|
||||||
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
|
if (
|
||||||
|
!forGroupBooking &&
|
||||||
|
sameDayForPair.every((s) => s.isGroupBookingOnly || s.status === "DRAFT")
|
||||||
|
)
|
||||||
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
|
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
|
||||||
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||||
}
|
}
|
||||||
@@ -513,20 +551,23 @@ export class SearchService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
|
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
|
||||||
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
|
* availability. The in-memory twin of `scheduleVisibilityWhere`; keep the two in step.
|
||||||
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
|
*
|
||||||
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
|
* `forGroupBooking` defaults false so existing single-arg callers (e.g. getAvailableDates,
|
||||||
* for a given channel only when its flag exactly matches that channel (normal schedules for
|
* the portal's calendar) keep the public rules — hiding both group-reserved and DRAFT trips.
|
||||||
* the portal, group-only schedules for Group Booking — never both from one channel).
|
|
||||||
*/
|
*/
|
||||||
private isBookableSchedule(
|
private isBookableSchedule(
|
||||||
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
|
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
|
||||||
forGroupBooking = false,
|
forGroupBooking = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const allowedStatuses = (
|
||||||
|
forGroupBooking ? STAFF_BOOKABLE_STATUSES : PUBLIC_BOOKABLE_STATUSES
|
||||||
|
) as readonly string[];
|
||||||
return (
|
return (
|
||||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
allowedStatuses.includes(s.status) &&
|
||||||
!s.isPackageOnly &&
|
!s.isPackageOnly &&
|
||||||
s.isGroupBookingOnly === forGroupBooking &&
|
// Staff see group-reserved trips as well as ordinary ones; the portal never does.
|
||||||
|
(forGroupBooking || !s.isGroupBookingOnly) &&
|
||||||
s.coachAssignments.length > 0
|
s.coachAssignments.length > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -637,11 +678,7 @@ export class SearchService {
|
|||||||
const [leg1Schedules, allCandidates] = await Promise.all([
|
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||||
this.prisma.trainSchedule.findMany({
|
this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
|
...scheduleVisibilityWhere(forGroupBooking),
|
||||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
|
||||||
isPackageOnly: false,
|
|
||||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
|
||||||
isGroupBookingOnly: forGroupBooking,
|
|
||||||
departureAt: { gte: dayStart, lt: dayEnd },
|
departureAt: { gte: dayStart, lt: dayEnd },
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
coachAssignments: { some: {} },
|
coachAssignments: { some: {} },
|
||||||
@@ -650,9 +687,7 @@ export class SearchService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.trainSchedule.findMany({
|
this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
...scheduleVisibilityWhere(forGroupBooking),
|
||||||
isPackageOnly: false,
|
|
||||||
isGroupBookingOnly: forGroupBooking,
|
|
||||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||||
coachAssignments: { some: {} },
|
coachAssignments: { some: {} },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,14 +190,18 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
|||||||
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking convenience",
|
||||||
description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
description: `Picks the requested number of available seats of the given class and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
||||||
|
|
||||||
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
|
**Fill order:** berth tier (Lower, then Middle, then Upper) → coach position in the consist → seat number within that coach. Seats fill one coach completely before moving to the next, so a party stays together by default. (Ordering by seat number alone used to interleave the coaches — seat numbers restart at 1 in every coach — which scattered a group one passenger per coach across the whole train.)
|
||||||
|
|
||||||
|
**\`coachId\` (optional)** pins the assignment to one coach. If that coach has too few suitable seats free, the call fails with 409 naming the coach and the shortfall rather than spilling into another coach.
|
||||||
|
|
||||||
|
This is a *convenience* for bulk/group flows, not the only way to allocate: staff can equally pick exact seats from \`GET /seats/seatmap/:scheduleId\` and hold them with \`POST /seats/hold\`, which accepts any mix of coaches and classes. Returns the same hold shape as POST /seats/hold.
|
||||||
|
|
||||||
For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict.
|
For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict.
|
||||||
|
|
||||||
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
|
Throws 409 with no partial hold created if fewer than the requested seats are available.`,
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
|
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
|
||||||
@ApiResponse({ status: 409, description: "Not enough seats available in the requested class" })
|
@ApiResponse({ status: 409, description: "Not enough seats available in the requested class" })
|
||||||
@@ -210,6 +214,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
dto.seatClassName,
|
dto.seatClassName,
|
||||||
passengerCount,
|
passengerCount,
|
||||||
dto.journeyDirection,
|
dto.journeyDirection,
|
||||||
|
dto.coachId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export class AutoAssignHoldDto {
|
|||||||
description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.',
|
description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.',
|
||||||
})
|
})
|
||||||
@IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection;
|
@IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'coach-uuid',
|
||||||
|
description:
|
||||||
|
'Restrict the assignment to a single coach, so the whole party is seated together. ' +
|
||||||
|
'Get candidate coaches from `GET /seats/coaches/:scheduleId`. ' +
|
||||||
|
'If that coach has too few suitable seats free the request fails with 409 naming the coach ' +
|
||||||
|
'and the shortfall — it never spills into another coach. ' +
|
||||||
|
'Omit to keep the previous behaviour (fill the whole coach type, coach by coach).',
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() coachId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReleaseHoldDto {
|
export class ReleaseHoldDto {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { SeatsService } from './seats.service';
|
import { SeatsService } from './seats.service';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { ConflictException } from '@nestjs/common';
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { SegmentsService } from '../segments/segments.service';
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
import { SystemConfigService } from '../system-config/system-config.service';
|
import { SystemConfigService } from '../system-config/system-config.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
@@ -31,6 +31,14 @@ describe('SeatsService - Auto Assign', () => {
|
|||||||
seatBlock: {
|
seatBlock: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
|
// autoAssignSeats reads the consist so it can order seats coach-by-coach, and looks the
|
||||||
|
// coach up by id to name it in the "not enough seats" error.
|
||||||
|
coachAssignment: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
coach: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSegmentsService = {
|
const mockSegmentsService = {
|
||||||
@@ -75,6 +83,11 @@ describe('SeatsService - Auto Assign', () => {
|
|||||||
{ stationId: 'destination-station', sequence: 1 },
|
{ stationId: 'destination-station', sequence: 1 },
|
||||||
]);
|
]);
|
||||||
mockPrisma.seatBlock.findMany.mockResolvedValue([]);
|
mockPrisma.seatBlock.findMany.mockResolvedValue([]);
|
||||||
|
// Single-coach consist by default — the multi-coach ordering cases override this.
|
||||||
|
mockPrisma.coachAssignment.findMany.mockResolvedValue([
|
||||||
|
{ coachId: 'coach-1', positionNumber: 1 },
|
||||||
|
]);
|
||||||
|
mockPrisma.coach.findUnique.mockResolvedValue({ number: 'RS-0001' });
|
||||||
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
|
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
|
||||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||||
coachTypeId: 'coach-type-1',
|
coachTypeId: 'coach-type-1',
|
||||||
@@ -235,5 +248,89 @@ describe('SeatsService - Auto Assign', () => {
|
|||||||
// Lower fills before Upper regardless of case.
|
// Lower fills before Upper regardless of case.
|
||||||
expect(result).toEqual(['lower-1', 'upper-1']);
|
expect(result).toEqual(['lower-1', 'upper-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('keeping a group together (regression)', () => {
|
||||||
|
// Every coach numbers its seats from 1, so ordering by seat number alone interleaves the
|
||||||
|
// coaches. This is the shape of the live data: 11 Regular Seat coaches, seats 1..118 in
|
||||||
|
// each. Booking VEKQF7 was handed seat "1" in three DIFFERENT coaches because of it.
|
||||||
|
const threeCoachConsist = [
|
||||||
|
{ coachId: 'coach-A', positionNumber: 3 },
|
||||||
|
{ coachId: 'coach-B', positionNumber: 5 },
|
||||||
|
{ coachId: 'coach-C', positionNumber: 6 },
|
||||||
|
];
|
||||||
|
const seatsAcrossThreeCoaches = [
|
||||||
|
{ id: 'A1', seatNumber: '1', coachId: 'coach-A', bedPosition: null },
|
||||||
|
{ id: 'A2', seatNumber: '2', coachId: 'coach-A', bedPosition: null },
|
||||||
|
{ id: 'A3', seatNumber: '3', coachId: 'coach-A', bedPosition: null },
|
||||||
|
{ id: 'B1', seatNumber: '1', coachId: 'coach-B', bedPosition: null },
|
||||||
|
{ id: 'B2', seatNumber: '2', coachId: 'coach-B', bedPosition: null },
|
||||||
|
{ id: 'C1', seatNumber: '1', coachId: 'coach-C', bedPosition: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('fills one coach before moving to the next, instead of taking seat 1 of every coach', async () => {
|
||||||
|
mockPrisma.coachAssignment.findMany.mockResolvedValue(threeCoachConsist);
|
||||||
|
mockPrisma.seat.findMany.mockResolvedValue(seatsAcrossThreeCoaches);
|
||||||
|
|
||||||
|
const result = await service.autoAssignSeats('trip-1', 3, 'ECONOMY_REGULAR');
|
||||||
|
|
||||||
|
// The whole party lands in the lowest-positioned coach. Before the fix this returned
|
||||||
|
// ['A1', 'B1', 'C1'] — one passenger per coach, spread down the train.
|
||||||
|
expect(result).toEqual(['A1', 'A2', 'A3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spills into the next coach by consist position only once the first is full', async () => {
|
||||||
|
mockPrisma.coachAssignment.findMany.mockResolvedValue(threeCoachConsist);
|
||||||
|
mockPrisma.seat.findMany.mockResolvedValue(seatsAcrossThreeCoaches);
|
||||||
|
|
||||||
|
const result = await service.autoAssignSeats('trip-1', 5, 'ECONOMY_REGULAR');
|
||||||
|
|
||||||
|
// coach-A exhausted (position 3), then coach-B (position 5) — never coach-C first.
|
||||||
|
expect(result).toEqual(['A1', 'A2', 'A3', 'B1', 'B2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confines the assignment to one coach when coachId is given', async () => {
|
||||||
|
mockPrisma.coachAssignment.findMany.mockResolvedValue([
|
||||||
|
{ coachId: 'coach-B', positionNumber: 5 },
|
||||||
|
]);
|
||||||
|
mockPrisma.seat.findMany.mockResolvedValue([
|
||||||
|
{ id: 'B1', seatNumber: '1', coachId: 'coach-B', bedPosition: null },
|
||||||
|
{ id: 'B2', seatNumber: '2', coachId: 'coach-B', bedPosition: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'coach-B');
|
||||||
|
|
||||||
|
expect(result).toEqual(['B1', 'B2']);
|
||||||
|
// The seat query must be scoped to that coach, not filtered afterwards.
|
||||||
|
expect(mockPrisma.seat.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: expect.objectContaining({ coachId: 'coach-B' }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses rather than spilling when the requested coach is short of seats', async () => {
|
||||||
|
mockPrisma.coachAssignment.findMany.mockResolvedValue([
|
||||||
|
{ coachId: 'coach-B', positionNumber: 5 },
|
||||||
|
]);
|
||||||
|
mockPrisma.coach.findUnique.mockResolvedValue({ number: 'VIP-0016' });
|
||||||
|
mockPrisma.seat.findMany.mockResolvedValue([
|
||||||
|
{ id: 'B1', seatNumber: '1', coachId: 'coach-B', bedPosition: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Never silently relocate people to another coach — say so and let staff choose.
|
||||||
|
await expect(
|
||||||
|
service.autoAssignSeats('trip-1', 4, 'ECONOMY_REGULAR', 'coach-B'),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.autoAssignSeats('trip-1', 4, 'ECONOMY_REGULAR', 'coach-B'),
|
||||||
|
).rejects.toThrow(/VIP-0016.*1 suitable seat.*4 were requested/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a coach that is not part of this departure', async () => {
|
||||||
|
mockPrisma.coachAssignment.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.autoAssignSeats('trip-1', 1, 'ECONOMY_REGULAR', 'coach-not-on-train'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1219,7 +1219,19 @@ export class SeatsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
/**
|
||||||
|
* Picks `count` free seats of `seatClassName` for a bulk/group booking.
|
||||||
|
*
|
||||||
|
* @param coachId restrict the pool to a single coach. When set, the group is kept together in
|
||||||
|
* that coach or the call fails — it never spills into another coach, because silently
|
||||||
|
* scattering a party is the exact behaviour this parameter exists to prevent.
|
||||||
|
*/
|
||||||
|
async autoAssignSeats(
|
||||||
|
scheduleId: string,
|
||||||
|
count: number,
|
||||||
|
seatClassName: string,
|
||||||
|
coachId?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
select: { originStationId: true, destinationStationId: true },
|
select: { originStationId: true, destinationStationId: true },
|
||||||
@@ -1249,12 +1261,25 @@ export class SeatsService {
|
|||||||
// Fixed physical fill order — lower berths first, then middle, then upper — not fare-driven.
|
// Fixed physical fill order — lower berths first, then middle, then upper — not fare-driven.
|
||||||
const BED_POSITION_ORDER: Record<string, number> = { lower: 0, middle: 1, upper: 2 };
|
const BED_POSITION_ORDER: Record<string, number> = { lower: 0, middle: 1, upper: 2 };
|
||||||
|
|
||||||
|
// Position of each coach in the consist, so seats can be ordered coach-by-coach. Without
|
||||||
|
// this the sort below has no way to keep a party together — see the comment on the sort.
|
||||||
|
const coachPositions = await this.prisma.coachAssignment.findMany({
|
||||||
|
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||||
|
select: { coachId: true, positionNumber: true },
|
||||||
|
});
|
||||||
|
if (coachId && coachPositions.length === 0) {
|
||||||
|
throw new NotFoundException('That coach is not assigned to this departure.');
|
||||||
|
}
|
||||||
|
const positionByCoachId = new Map(coachPositions.map((a) => [a.coachId, a.positionNumber]));
|
||||||
|
|
||||||
const allSeatsOnSchedule = await this.prisma.seat.findMany({
|
const allSeatsOnSchedule = await this.prisma.seat.findMany({
|
||||||
where: {
|
where: {
|
||||||
coach: {
|
coach: {
|
||||||
coachTypeId: seatClass.coachTypeId,
|
coachTypeId: seatClass.coachTypeId,
|
||||||
assignments: { some: { scheduleId } },
|
assignments: { some: { scheduleId } },
|
||||||
},
|
},
|
||||||
|
// Restrict to one coach when the caller named one, so a group stays together.
|
||||||
|
...(coachId ? { coachId } : {}),
|
||||||
seatNumber: { not: '' },
|
seatNumber: { not: '' },
|
||||||
// Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this
|
// Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this
|
||||||
// method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at
|
// method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at
|
||||||
@@ -1263,17 +1288,29 @@ export class SeatsService {
|
|||||||
// are still excluded below via the schedule-scoped SeatBlock check.
|
// are still excluded below via the schedule-scoped SeatBlock check.
|
||||||
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }],
|
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }],
|
||||||
},
|
},
|
||||||
orderBy: [{ coach: { number: 'asc' } }],
|
select: { id: true, seatNumber: true, bedPosition: true, coachId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lower → Middle → Upper, then ascending seat number within a tier (seatNumber is a string
|
// Order: bed tier → coach position in the consist → seat number within the coach.
|
||||||
// column, so DB/lexicographic ordering would sort "10" before "2" — compare numerically here).
|
//
|
||||||
|
// The coach term is load-bearing and used to be missing. `seatNumber` restarts at 1 in every
|
||||||
|
// coach (each Regular Seat coach here holds seats 1-118), so ordering by seat number alone
|
||||||
|
// interleaves the coaches: seat 1 of coach A, seat 1 of coach B, seat 1 of coach C… before
|
||||||
|
// seat 2 of coach A. A party of 3 was handed seat "1" in three DIFFERENT coaches, and a
|
||||||
|
// party of 40 would have been spread over the whole train one or two at a time — the exact
|
||||||
|
// opposite of what a group booking is for.
|
||||||
|
//
|
||||||
|
// seatNumber is a string column, so lexicographic ordering would also put "10" before "2";
|
||||||
|
// compare numerically.
|
||||||
const seats = allSeatsOnSchedule
|
const seats = allSeatsOnSchedule
|
||||||
.filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase()))
|
.filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase()))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0)
|
const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0)
|
||||||
- (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0);
|
- (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0);
|
||||||
if (tierDiff !== 0) return tierDiff;
|
if (tierDiff !== 0) return tierDiff;
|
||||||
|
const coachDiff = (positionByCoachId.get(a.coachId) ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
- (positionByCoachId.get(b.coachId) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
if (coachDiff !== 0) return coachDiff;
|
||||||
return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10);
|
return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1300,11 +1337,26 @@ export class SeatsService {
|
|||||||
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
|
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
|
||||||
|
|
||||||
if (availableSeats.length < count) {
|
if (availableSeats.length < count) {
|
||||||
|
if (coachId) {
|
||||||
|
// Name the coach and the shortfall so the caller can pick another coach or split the
|
||||||
|
// party deliberately. Never fall back to another coach here: quietly relocating people
|
||||||
|
// is precisely what the coach-scoped path exists to stop.
|
||||||
|
const coach = await this.prisma.coach.findUnique({
|
||||||
|
where: { id: coachId },
|
||||||
|
select: { number: true },
|
||||||
|
});
|
||||||
|
throw new ConflictException(
|
||||||
|
`Coach ${coach?.number ?? coachId} has only ${availableSeats.length} suitable seat` +
|
||||||
|
`${availableSeats.length === 1 ? '' : 's'} free, but ${count} were requested. ` +
|
||||||
|
'Choose another coach, or split the group across coaches explicitly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`);
|
throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// availableSeats is already ordered lower→middle→upper, ascending seat number within a
|
// availableSeats is ordered lower→middle→upper, then by coach position, then by seat number
|
||||||
// tier — take the first `count` in that order, spilling into the next tier once one runs out.
|
// — so the first `count` fill up one coach before moving to the next, keeping a party
|
||||||
|
// together by default rather than interleaving it across the train.
|
||||||
return availableSeats.slice(0, count).map((s) => s.id);
|
return availableSeats.slice(0, count).map((s) => s.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,8 +1374,9 @@ export class SeatsService {
|
|||||||
seatClassName: string,
|
seatClassName: string,
|
||||||
passengerCount: number,
|
passengerCount: number,
|
||||||
journeyDirection?: JourneyDirection,
|
journeyDirection?: JourneyDirection,
|
||||||
|
coachId?: string,
|
||||||
) {
|
) {
|
||||||
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
|
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName, coachId);
|
||||||
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
|
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
|
||||||
// "group-1", "group-2"... would collide with any other still-active group-booking hold on
|
// "group-1", "group-2"... would collide with any other still-active group-booking hold on
|
||||||
// the same schedule (e.g. an abandoned/retried attempt, or two staff members booking the
|
// the same schedule (e.g. an abandoned/retried attempt, or two staff members booking the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AuditService } from '../../common/audit.service';
|
|||||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||||
import { ActingUser } from '../../common/acting-user';
|
import { ActingUser } from '../../common/acting-user';
|
||||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||||
|
import { HUMAN_ALLOCATED_BOOKING_SOURCES } from '../bookings/booking-source.constants';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
interface OfflineValidation {
|
interface OfflineValidation {
|
||||||
@@ -208,6 +209,18 @@ export class TicketsService {
|
|||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
// A staff-chosen allocation is not ours to rearrange. Silently moving one member of a group
|
||||||
|
// out of the coach the rest of the party is sitting in — after staff picked those exact
|
||||||
|
// seats and the customer was shown them — is worse than failing loudly, so let generate()
|
||||||
|
// surface the seat conflict instead and let a person decide.
|
||||||
|
if (HUMAN_ALLOCATED_BOOKING_SOURCES.includes(booking.source)) {
|
||||||
|
this.logger.log(
|
||||||
|
`Booking ${bookingId} has source=${booking.source} (seats chosen by staff) — skipping ` +
|
||||||
|
`smart seat reassignment and generating tickets on the original seats.`,
|
||||||
|
);
|
||||||
|
return this.generate(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
// Seats held by any active SeatHold (not yet expired)
|
// Seats held by any active SeatHold (not yet expired)
|
||||||
const heldSeatIds = await this.prisma.seatHold.findMany({
|
const heldSeatIds = await this.prisma.seatHold.findMany({
|
||||||
where: { expiresAt: { gt: new Date() } },
|
where: { expiresAt: { gt: new Date() } },
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ function emptySearchMessage(reason: SearchEmptyReason | undefined): string {
|
|||||||
case 'PACKAGE_ONLY':
|
case 'PACKAGE_ONLY':
|
||||||
return `Departures on this date are reserved for travel packages, not regular ticketing.`;
|
return `Departures on this date are reserved for travel packages, not regular ticketing.`;
|
||||||
case 'GROUP_BOOKING_ONLY':
|
case 'GROUP_BOOKING_ONLY':
|
||||||
// isGroupBookingOnly is an exclusive partition — this page only ever sees group-booking
|
// The staff channel is a superset of the public one — it sees ordinary, group-reserved
|
||||||
// schedules, so an empty result here means a regular (non-group) train runs on this date
|
// and unpublished DRAFT trips alike — so the backend never returns this code here. Kept
|
||||||
// but nothing has been set up for group booking specifically.
|
// only so the switch stays exhaustive against the shared SearchEmptyReasonCode union.
|
||||||
return `A regular train runs from ${o} to ${d} on this date, but no schedule has been set up for group booking yet — ask fleet/schedule management to create one, or try another date.`;
|
return `No departures from ${o} to ${d} are available for booking on this date.`;
|
||||||
case 'CHECKIN_CLOSED':
|
case 'CHECKIN_CLOSED':
|
||||||
return `Check-in has already closed for every departure on this date.`;
|
return `Check-in has already closed for every departure on this date.`;
|
||||||
case 'FULLY_BOOKED':
|
case 'FULLY_BOOKED':
|
||||||
@@ -285,7 +285,8 @@ function GroupBookingPageContent() {
|
|||||||
journeyType: tripType,
|
journeyType: tripType,
|
||||||
returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined,
|
returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined,
|
||||||
nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other',
|
nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other',
|
||||||
channel: 'GROUP_BOOKING',
|
// The staff channel comes from the route groupBookingApi.searchTrips calls
|
||||||
|
// (POST /search/group), not from a field in the body.
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ export interface SearchTripsRequest {
|
|||||||
returnDate?: string;
|
returnDate?: string;
|
||||||
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
|
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
|
||||||
nationality?: string;
|
nationality?: string;
|
||||||
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
|
// There is no `channel` field any more. It used to select the staff view from the request
|
||||||
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
|
// BODY of the public `POST /search`, which meant anyone could ask for it. The channel is now
|
||||||
* schedules never show up here, and group-only schedules never show up in the portal. */
|
// decided by the route: `POST /search/group` is permission-guarded (see searchTrips below).
|
||||||
channel?: 'PORTAL' | 'GROUP_BOOKING';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleClassOption {
|
export interface ScheduleClassOption {
|
||||||
@@ -227,8 +226,14 @@ export interface InitiatePaymentResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const groupBookingApi = {
|
export const groupBookingApi = {
|
||||||
|
/**
|
||||||
|
* The staff search channel. Same body and response as the public `POST /search`, but this
|
||||||
|
* route also returns unpublished DRAFT trips and trips reserved for groups — and, unlike
|
||||||
|
* before, ordinary scheduled trips too, so a group can be booked onto a normal service.
|
||||||
|
* Requires bookings:create / bookings:manage; the shared apiClient sends the IAM token.
|
||||||
|
*/
|
||||||
searchTrips: (dto: SearchTripsRequest) =>
|
searchTrips: (dto: SearchTripsRequest) =>
|
||||||
apiClient.post<SearchTripsResponse>('/search', dto),
|
apiClient.post<SearchTripsResponse>('/search/group', dto),
|
||||||
|
|
||||||
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
|
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user