feat(group-booking): draft schedules, coach-scoped seating, mixed classes

This commit is contained in:
Abubeker Yasin
2026-09-08 16:07:46 +03:00
parent b08fc85aaf
commit 3fb019fd07
28 changed files with 1068 additions and 107 deletions

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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 {

View File

@@ -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],
})

View File

@@ -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: {} },
},