mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
feat(group-booking): draft schedules, coach-scoped seating, mixed classes
This commit is contained in:
@@ -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' })
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'PORTAL',
|
||||
enum: ['PORTAL', 'GROUP_BOOKING'],
|
||||
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.',
|
||||
})
|
||||
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
|
||||
// NOTE: there is deliberately no `channel` field.
|
||||
//
|
||||
// It used to live here, which meant the calling surface was chosen by the request body on a
|
||||
// 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
|
||||
// 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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { GroupSearchController } from './group-search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
@@ -7,7 +8,9 @@ import { SegmentsModule } from '../segments/segments.module';
|
||||
|
||||
@Module({
|
||||
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],
|
||||
exports: [SearchService],
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import { SegmentsService } from "../segments/segments.service";
|
||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||
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";
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
@@ -72,6 +72,52 @@ const SCHEDULE_INCLUDE = {
|
||||
},
|
||||
} 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()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@@ -81,11 +127,16 @@ export class SearchService {
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
|
||||
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
|
||||
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
|
||||
const forGroupBooking = dto.channel === "GROUP_BOOKING";
|
||||
/**
|
||||
* @param forGroupBooking the staff bulk-booking channel. A strict SUPERSET of the public one
|
||||
* (see `scheduleVisibilityWhere`): staff see every bookable trip — ordinary, group-reserved,
|
||||
* and unpublished DRAFT — so a group can be added to an ordinary scheduled service.
|
||||
*
|
||||
* 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([
|
||||
this.searchSchedules(
|
||||
dto.originStationId,
|
||||
@@ -244,19 +295,8 @@ export class SearchService {
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
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 = {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
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,
|
||||
...scheduleVisibilityWhere(forGroupBooking),
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
};
|
||||
@@ -342,12 +382,7 @@ export class SearchService {
|
||||
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
// EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
|
||||
// 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,
|
||||
...scheduleVisibilityWhere(forGroupBooking),
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -438,11 +473,14 @@ export class SearchService {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
if (sameDayForPair.every((s) => s.isPackageOnly))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
||||
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
|
||||
// code covers both directions: the portal finding only group-reserved schedules, and Group
|
||||
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
|
||||
// the right copy per caller.
|
||||
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
|
||||
// Public channel only: every trip that day is withheld from the portal — either reserved
|
||||
// for a group or not yet published. The staff channel is a superset and cannot reach this
|
||||
// case, so the branch is skipped for it (a staff search that found nothing bookable fell
|
||||
// through on status or coaches, which the codes below cover).
|
||||
if (
|
||||
!forGroupBooking &&
|
||||
sameDayForPair.every((s) => s.isGroupBookingOnly || s.status === "DRAFT")
|
||||
)
|
||||
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
|
||||
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
|
||||
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
|
||||
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
|
||||
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
|
||||
* for a given channel only when its flag exactly matches that channel (normal schedules for
|
||||
* the portal, group-only schedules for Group Booking — never both from one channel).
|
||||
* availability. The in-memory twin of `scheduleVisibilityWhere`; keep the two in step.
|
||||
*
|
||||
* `forGroupBooking` defaults false so existing single-arg callers (e.g. getAvailableDates,
|
||||
* the portal's calendar) keep the public rules — hiding both group-reserved and DRAFT trips.
|
||||
*/
|
||||
private isBookableSchedule(
|
||||
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
|
||||
forGroupBooking = false,
|
||||
): boolean {
|
||||
const allowedStatuses = (
|
||||
forGroupBooking ? STAFF_BOOKABLE_STATUSES : PUBLIC_BOOKABLE_STATUSES
|
||||
) as readonly string[];
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
allowedStatuses.includes(s.status) &&
|
||||
!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
|
||||
);
|
||||
}
|
||||
@@ -637,11 +678,7 @@ export class SearchService {
|
||||
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
...scheduleVisibilityWhere(forGroupBooking),
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -650,9 +687,7 @@ export class SearchService {
|
||||
}),
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
...scheduleVisibilityWhere(forGroupBooking),
|
||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||
coachAssignments: { some: {} },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user