mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Update group booking and package
This commit is contained in:
@@ -4,9 +4,11 @@
|
||||
import "dotenv/config";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import helmet from "helmet";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
|
||||
@@ -23,10 +25,20 @@ if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS ===
|
||||
async function bootstrap() {
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
// Security headers. crossOriginResourcePolicy defaults to 'same-origin' in helmet, which
|
||||
// would make browsers refuse to actually render package images (served from this origin)
|
||||
// inside <img> tags on the portal/backoffice (different origins) even though the request
|
||||
// itself succeeds — relaxed to 'cross-origin' since this API already serves all its JSON to
|
||||
// those exact same origins per the CORS allowlist below; nothing new is being exposed.
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
|
||||
// Serves apps/edr-passenger-api/public/* at the site root — package images live at
|
||||
// public/uploads/packages/<file>, reachable as GET /uploads/packages/<file>. Local-disk
|
||||
// storage is a deliberate, explicit stopgap (see packages.service.ts's uploadImage) rather
|
||||
// than this app's usual MinIO-backed upload pattern (see modules/support's attachments).
|
||||
app.useStaticAssets(join(__dirname, "..", "public"));
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
|
||||
@@ -368,26 +368,22 @@ export class BookingsController {
|
||||
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
||||
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
|
||||
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY.
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does.
|
||||
|
||||
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
|
||||
|
||||
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
|
||||
|
||||
If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
|
||||
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
||||
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
||||
try {
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true });
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.seatsService.releaseHold(dto.holdId);
|
||||
} catch (releaseErr) {
|
||||
// Best-effort — the hold may already be gone (e.g. it expired mid-request). The
|
||||
// original booking-creation error is what the caller actually needs to see.
|
||||
}
|
||||
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
||||
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,10 @@ export class GuestBookingService {
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
// A free child (no seatId — see passengersWithFares above) has nothing to connect to;
|
||||
// `connect: { id: undefined }` throws PrismaClientValidationError immediately if this
|
||||
// filter is missing, so it's never optional here despite the map below looking safe.
|
||||
create: passengersWithFares.filter((p) => p.seatId).map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
@@ -763,7 +766,7 @@ export class GuestBookingService {
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { diskStorage } from 'multer';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname, join } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
|
||||
/** Multipart field name carrying the package image file. */
|
||||
export const PACKAGE_IMAGE_FIELD = 'image';
|
||||
|
||||
/** Local-disk stopgap (see packages.service.ts) — not this app's usual MinIO-backed upload pattern. */
|
||||
export const PACKAGE_IMAGE_UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads', 'packages');
|
||||
|
||||
export const PACKAGE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
export const packageImageMulterOptions: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
// mkdir on every request rather than once at module load — this directory is
|
||||
// gitignored (see public/uploads/packages/.gitignore) so a fresh checkout/deploy
|
||||
// won't have it yet, and recursive mkdir on an already-existing dir is a no-op.
|
||||
mkdirSync(PACKAGE_IMAGE_UPLOAD_DIR, { recursive: true });
|
||||
cb(null, PACKAGE_IMAGE_UPLOAD_DIR);
|
||||
},
|
||||
// Unique filename so two packages (or two uploads for the same package) never collide —
|
||||
// never trust or reuse the original filename.
|
||||
filename: (_req, file, cb) => {
|
||||
cb(null, `${randomUUID()}${extname(file.originalname).toLowerCase()}`);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: PACKAGE_IMAGE_MAX_BYTES,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||
cb(new BadRequestException('Image must be JPEG, PNG, WEBP, or GIF.'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, UseInterceptors, UploadedFile, Request, Query, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
|
||||
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
@@ -148,6 +150,29 @@ export class PackagesController {
|
||||
return this.service.remove(id, cascade === 'true');
|
||||
}
|
||||
|
||||
@Post(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { [PACKAGE_IMAGE_FIELD]: { type: 'string', format: 'binary' } } } })
|
||||
@ApiOperation({
|
||||
summary: 'Upload or replace a package image (admin)',
|
||||
description: 'JPEG/PNG/WEBP/GIF, max 5MB. Replaces and deletes the previous image file if one exists — works the same whether the package currently has an image or not, so this one route covers both the initial upload and later replacement.',
|
||||
})
|
||||
uploadImage(@Param('id') id: string, @UploadedFile() file?: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('No image file provided.');
|
||||
return this.service.uploadImage(id, file);
|
||||
}
|
||||
|
||||
@Delete(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
|
||||
removeImage(@Param('id') id: string) {
|
||||
return this.service.removeImage(id);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||
@@ -7,6 +7,9 @@ import { BookingsService } from '../bookings/bookings.service';
|
||||
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
|
||||
import { PACKAGE_IMAGE_UPLOAD_DIR } from './package-image-upload.options';
|
||||
import { join } from 'path';
|
||||
import { unlink } from 'fs/promises';
|
||||
|
||||
/** Package-specific fare rules */
|
||||
const PKG_MAX_ADULTS = 5;
|
||||
@@ -46,6 +49,8 @@ function generateRef(): string {
|
||||
|
||||
@Injectable()
|
||||
export class PackagesService {
|
||||
private readonly logger = new Logger(PackagesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -325,6 +330,55 @@ export class PackagesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Public URL prefix main.ts's app.useStaticAssets serves public/uploads/packages under. */
|
||||
private readonly PACKAGE_IMAGE_URL_PREFIX = '/uploads/packages/';
|
||||
|
||||
private packageImagePublicUrl(filename: string): string {
|
||||
const base = (process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 4000}`).replace(/\/$/, '');
|
||||
return `${base}${this.PACKAGE_IMAGE_URL_PREFIX}${filename}`;
|
||||
}
|
||||
|
||||
/** Best-effort delete of the file backing a package's current imageUrl — never throws, since a
|
||||
* missing file (already deleted, moved, or from before this feature existed) shouldn't block
|
||||
* the DB update that's actually replacing/clearing the field. */
|
||||
private async deletePackageImageFile(imageUrl: string | null): Promise<void> {
|
||||
if (!imageUrl) return;
|
||||
const idx = imageUrl.indexOf(this.PACKAGE_IMAGE_URL_PREFIX);
|
||||
if (idx === -1) return; // not a file this app manages (e.g. an external URL) — nothing to delete
|
||||
const filename = imageUrl.slice(idx + this.PACKAGE_IMAGE_URL_PREFIX.length);
|
||||
if (!filename || filename.includes('/') || filename.includes('..')) return; // defensive: never touch paths outside the uploads dir
|
||||
try {
|
||||
await unlink(join(PACKAGE_IMAGE_UPLOAD_DIR, filename));
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') this.logger.warn(`Failed to delete package image file ${filename}: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Multer's diskStorage has already written the file to PACKAGE_IMAGE_UPLOAD_DIR by the time
|
||||
* this runs (see package-image-upload.options.ts) — this just points the package at it and
|
||||
* cleans up whatever it's replacing. */
|
||||
async uploadImage(id: string, file: Express.Multer.File) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
|
||||
const imageUrl = this.packageImagePublicUrl(file.filename);
|
||||
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeImage(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
if (!pkg.imageUrl) return this.prisma.travelPackage.findUnique({ where: { id } });
|
||||
|
||||
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl: null } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl: null } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async addTier(packageId: string, dto: CreatePriceTierDto) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
@@ -369,6 +423,7 @@ export class PackagesService {
|
||||
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
|
||||
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
|
||||
await this.prisma.travelPackage.delete({ where: { id } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, IsBoolean, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -56,6 +56,9 @@ export class CreateScheduleDto {
|
||||
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() @IsBoolean() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@@ -64,6 +67,7 @@ export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
|
||||
@@ -263,6 +263,8 @@ export class SchedulesService {
|
||||
arrivalAt: arr,
|
||||
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
|
||||
stopsCount: Math.max(0, route.stops.length - 2),
|
||||
isPackageOnly: dto.isPackageOnly ?? false,
|
||||
isGroupBookingOnly: dto.isGroupBookingOnly ?? false,
|
||||
},
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
@@ -1000,6 +1002,7 @@ export class SchedulesService {
|
||||
|
||||
if (dto.status) updateData.status = dto.status;
|
||||
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
|
||||
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||
|
||||
@@ -27,6 +27,13 @@ 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;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
|
||||
@@ -82,6 +82,10 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
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";
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.originStationId,
|
||||
@@ -90,6 +94,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.originStationId,
|
||||
@@ -98,6 +103,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -112,8 +118,9 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking),
|
||||
]);
|
||||
return {
|
||||
journeyType: "ONE_WAY",
|
||||
@@ -133,6 +140,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.destinationStationId,
|
||||
@@ -141,6 +149,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -172,6 +181,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
inbound.length === 0
|
||||
@@ -182,13 +192,14 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
outbound.length === 0
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking)
|
||||
: Promise.resolve(undefined),
|
||||
inbound.length === 0
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
return {
|
||||
@@ -223,6 +234,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -241,6 +253,10 @@ export class SearchService {
|
||||
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,
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
};
|
||||
@@ -310,6 +326,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -329,6 +346,8 @@ export class SearchService {
|
||||
// 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 },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -363,6 +382,7 @@ export class SearchService {
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
forGroupBooking = false,
|
||||
): Promise<Passenger.ISearchEmptyReason> {
|
||||
const [origin, destination] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
|
||||
@@ -393,6 +413,7 @@ export class SearchService {
|
||||
select: {
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
isGroupBookingOnly: true,
|
||||
departureAt: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
@@ -409,13 +430,20 @@ export class SearchService {
|
||||
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
// (right status, not package-only, not group-booking-only unless this IS a group-booking
|
||||
// search, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
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))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
}
|
||||
|
||||
@@ -483,11 +511,22 @@ export class SearchService {
|
||||
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
|
||||
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
private isBookableSchedule(
|
||||
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
|
||||
forGroupBooking = false,
|
||||
): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.isGroupBookingOnly === forGroupBooking &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
@@ -542,6 +581,7 @@ export class SearchService {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
isGroupBookingOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
@@ -581,6 +621,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -599,6 +640,8 @@ export class SearchService {
|
||||
// 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,
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -609,6 +652,7 @@ export class SearchService {
|
||||
where: {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||
coachAssignments: { some: {} },
|
||||
},
|
||||
|
||||
@@ -191,10 +191,12 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
||||
description: `Picks the requested number of available seats of the given class (preferring a contiguous row) 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 (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.
|
||||
|
||||
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.
|
||||
|
||||
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.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
|
||||
@@ -207,6 +209,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
||||
dto.destinationStationId,
|
||||
dto.seatClassName,
|
||||
passengerCount,
|
||||
dto.journeyDirection,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ export class AutoAssignHoldDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
|
||||
@IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: JourneyDirection,
|
||||
example: JourneyDirection.OUTBOUND,
|
||||
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;
|
||||
}
|
||||
|
||||
export class ReleaseHoldDto {
|
||||
|
||||
@@ -920,6 +920,7 @@ export class SeatsService {
|
||||
destinationStationId: string,
|
||||
seatClassName: string,
|
||||
passengerCount: number,
|
||||
journeyDirection?: JourneyDirection,
|
||||
) {
|
||||
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
|
||||
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
|
||||
@@ -933,6 +934,7 @@ export class SeatsService {
|
||||
scheduleId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
journeyDirection,
|
||||
passengers,
|
||||
} as HoldSeatsDto);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user