mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge branch 'dev' into reschedule
This commit is contained in:
@@ -59,6 +59,7 @@
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"minio": "7.1.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TrainSchedule" ADD COLUMN IF NOT EXISTS "isGroupBookingOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TravelPackage" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT;
|
||||
@@ -373,6 +373,7 @@ model TrainSchedule {
|
||||
carbonRating String @default("A")
|
||||
notes String?
|
||||
isPackageOnly Boolean @default(false)
|
||||
isGroupBookingOnly Boolean @default(false)
|
||||
train Train @relation(fields: [trainId], references: [id])
|
||||
route Route? @relation(fields: [routeId], references: [id])
|
||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||
@@ -1586,6 +1587,7 @@ model TravelPackage {
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
imageUrl String?
|
||||
status PackageStatus @default(DRAFT)
|
||||
outboundScheduleId String
|
||||
returnScheduleId String
|
||||
|
||||
@@ -3,6 +3,7 @@ import { APP_FILTER } from "@nestjs/core";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
@@ -83,6 +84,17 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module';
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
// Named tiers only — no APP_GUARD is registered, so nothing is throttled until a
|
||||
// controller opts in with @UseGuards(ThrottlerGuard). AuthController is currently the
|
||||
// only one that does, because the staged sign-in exposes an account-existence lookup.
|
||||
ThrottlerModule.forRoot([
|
||||
// 20/min, not the 5/min the commented-out decorators suggested: the staged sign-in
|
||||
// legitimately costs 3-5 calls (lookup → request code → resend → complete → a retry
|
||||
// after a typo), and the throttler keys on IP, so users sharing a NAT or mobile CGNAT
|
||||
// address share the budget. 5 would lock real passengers out.
|
||||
{ name: "auth", limit: 20, ttl: 60_000 },
|
||||
{ name: "strict", limit: 20, ttl: 60_000 },
|
||||
]),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -98,8 +110,11 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module';
|
||||
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
|
||||
[EOtpType.RESET_PASSWORD]: ({ route }) =>
|
||||
`Reset your EDR Passenger password using this link: ${route}`,
|
||||
[EOtpType.SET_PASSWORD]: ({ route }) =>
|
||||
`Set your EDR Passenger password using this link: ${route}`,
|
||||
// Carries the bare code as well as the link: the staged sign-in asks for the code
|
||||
// inline, while the link is still what a `/set-password` deep link from an older SMS
|
||||
// relies on. `OtpMessageContext` supplies both.
|
||||
[EOtpType.SET_PASSWORD]: ({ otp, route }) =>
|
||||
`Your EDR Passenger code is ${otp}. Or set your password here: ${route}`,
|
||||
},
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
export function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
export type ResolvedIdentifier = {
|
||||
kind: 'email' | 'phone';
|
||||
/** Lower-cased email, or null when the input is a phone number. */
|
||||
email: string | null;
|
||||
/** Every stored shape the number could have, or [] when the input is an email. */
|
||||
phoneVariants: string[];
|
||||
};
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function resolveIdentifier(raw: string): ResolvedIdentifier {
|
||||
const trimmed = raw.trim();
|
||||
if (EMAIL_RE.test(trimmed)) {
|
||||
return { kind: 'email', email: trimmed.toLowerCase(), phoneVariants: [] };
|
||||
}
|
||||
return { kind: 'phone', email: null, phoneVariants: normalizePhoneVariants(trimmed) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone numbers reach us in every shape the UI allows — `+251912345678`, `0912345678`,
|
||||
* `912345678`, and the same again with spaces or dashes. Comparing two of them as raw strings
|
||||
* is a coin flip, so anything that decides access on a phone number must normalise first.
|
||||
*
|
||||
* `+251912345678` → `+2519****678`. Shown on the OTP screen so the passenger can tell which
|
||||
* number the code went to without the server handing back the full number to an unauthenticated
|
||||
* caller.
|
||||
*/
|
||||
export function maskPhone(phone: string): string {
|
||||
const stripped = phone.replace(/[^\d+]/g, '');
|
||||
if (stripped.length <= 7) return stripped;
|
||||
const head = stripped.slice(0, stripped.startsWith('+') ? 5 : 4);
|
||||
const tail = stripped.slice(-3);
|
||||
return `${head}${'*'.repeat(4)}${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses a number to a single canonical E.164 form so two values can be compared directly.
|
||||
* Mirrors `PassengerAuthService.standardizePhone`, plus the bare-9-digit case the passenger
|
||||
* form produces (its input sits behind a fixed `+251` prefix control).
|
||||
*
|
||||
*/
|
||||
export function normalizePhone(phone?: string | null): string | null {
|
||||
if (!phone) return null;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { PassengerAuthService } from "./passenger-auth.service";
|
||||
import {
|
||||
RegisterDto,
|
||||
@@ -28,12 +29,19 @@ import {
|
||||
ResendRegistrationCodeDto,
|
||||
FaydaRequestPasswordSetupDto,
|
||||
FaydaVerifyAndLoginDto,
|
||||
IdentifierLookupDto,
|
||||
PasswordSetupRequestDto,
|
||||
PasswordSetupCompleteDto,
|
||||
} from "./auth.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
|
||||
@ApiTags("Passenger Auth")
|
||||
@Controller("auth")
|
||||
// @Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in
|
||||
// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the
|
||||
// guard app-wide would change the behaviour of every other module at the same time.
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ auth: { limit: 20, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||
|
||||
@@ -189,6 +197,63 @@ export class AuthController {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
|
||||
@Post("identifier/lookup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// Tighter than the rest of the controller: this is the endpoint that answers "does this
|
||||
// account exist", so it is the one worth making expensive to sweep. Still roomy enough
|
||||
// that a passenger correcting a typo two or three times is unaffected.
|
||||
@Throttle({ auth: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({
|
||||
summary: "Step 1 of sign-in — decide what to ask the user for next",
|
||||
description:
|
||||
"Takes a phone number or an email and reports whether the account exists and whether it " +
|
||||
"already has a password. PASSWORD → ask for the password. NEEDS_PASSWORD_SETUP → send a " +
|
||||
"code and let them set one. NOT_FOUND → sign them up.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "{ status, method?, maskedPhone? } — never returns email or user id",
|
||||
})
|
||||
@ApiBody({ type: IdentifierLookupDto })
|
||||
lookupIdentifier(@Body() dto: IdentifierLookupDto) {
|
||||
return this.passengerAuthService.lookupIdentifier(dto.identifier);
|
||||
}
|
||||
|
||||
@Post("password-setup/request")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Send the SMS code that lets an account with no password set one",
|
||||
description:
|
||||
"Covers Fayda-created accounts and abandoned registrations alike. Always returns " +
|
||||
"{ sent: true } regardless of whether the account exists.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "{ sent: true }" })
|
||||
@ApiBody({ type: PasswordSetupRequestDto })
|
||||
requestPasswordSetup(@Body() dto: PasswordSetupRequestDto, @Request() req: any) {
|
||||
return this.passengerAuthService.requestPasswordSetup(dto.identifier, req);
|
||||
}
|
||||
|
||||
@Post("password-setup/complete")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Redeem the code, set the password, and sign in",
|
||||
description:
|
||||
"Accepts both set-password codes (from password-setup/request) and verify-phone-number " +
|
||||
"codes (from POST /auth/register), so one screen finishes both branches.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Same shape as POST /auth/login — token, refreshToken and user.",
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid or expired code" })
|
||||
@ApiBody({ type: PasswordSetupCompleteDto })
|
||||
completePasswordSetup(@Body() dto: PasswordSetupCompleteDto) {
|
||||
return this.passengerAuthService.completePasswordSetup(dto);
|
||||
}
|
||||
|
||||
@Post("fayda/request-password-setup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsStrongPassword,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
|
||||
@IsString()
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in. One field: the passenger types either their phone number
|
||||
* or their email and the server decides which of the three branches follows.
|
||||
*/
|
||||
export class IdentifierLookupDto {
|
||||
@ApiProperty({
|
||||
example: '+251912345678',
|
||||
description: 'Phone number or email address — the server detects which',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2a: ask for the SMS code that lets an account with no password set one. */
|
||||
export class PasswordSetupRequestDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2b: redeem the code, set the password, and receive a session in one call. */
|
||||
export class PasswordSetupCompleteDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '6-digit code received via SMS' })
|
||||
@IsString()
|
||||
@Length(4, 10)
|
||||
otp: string;
|
||||
|
||||
// The credential is written directly against iam.user_credentials rather than through
|
||||
// the IAM's own set-password route, so the IAM's @IsStrongPassword rule has to be
|
||||
// restated here or weak passwords would slip in unvalidated.
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsStrongPassword({
|
||||
minLength: 8,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
minNumbers: 1,
|
||||
minSymbols: 1,
|
||||
})
|
||||
newPassword: string;
|
||||
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
@@ -13,7 +14,22 @@ import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/au
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, PasswordSetupCompleteDto } from './auth.dto';
|
||||
import { maskPhone, resolveIdentifier } from '../../common/utils/phone.utils';
|
||||
|
||||
/**
|
||||
* The `iam.users` columns every sign-in branch needs. Kept separate from `IamUserRow`
|
||||
* (which is profile-shaped) because the auth branches key off credential state, not metadata.
|
||||
*/
|
||||
type IamAuthRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
type IamUserRow = {
|
||||
id: string;
|
||||
@@ -170,9 +186,19 @@ export class PassengerAuthService {
|
||||
async login(dto: LoginDto, req: any) {
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
// `dto.email` may hold an email OR a phone number, in any of the shapes a passenger might
|
||||
// type. Resolve it to the exact string the IAM stores before handing it over: the IAM
|
||||
// matches the identifier literally, so someone entering `0912…` for a number stored as
|
||||
// `+2519…` would be told their credentials are invalid despite a correct password.
|
||||
const known = await this.findUserByIdentifier(dto.email);
|
||||
const loginIdentifier = known?.email ?? known?.phone_number ?? dto.email;
|
||||
|
||||
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
||||
try {
|
||||
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
|
||||
iamResult = await iamAuthService.login({
|
||||
email: loginIdentifier,
|
||||
password: dto.password,
|
||||
});
|
||||
} catch {
|
||||
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -184,14 +210,16 @@ export class PassengerAuthService {
|
||||
|
||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||
|
||||
// `dto.email` may hold an email OR a phone number (passengers without an email log in
|
||||
// with their phone). Match on either so the post-auth lookup works regardless of which
|
||||
// identifier was used.
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[dto.email],
|
||||
);
|
||||
const iamUser = iamRows[0];
|
||||
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
|
||||
// the resolve missed but the IAM authenticated anyway.
|
||||
let iamUser: { id: string; email: string | null } | null = known;
|
||||
if (!iamUser) {
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[loginIdentifier],
|
||||
);
|
||||
iamUser = iamRows[0] ?? null;
|
||||
}
|
||||
if (!iamUser) {
|
||||
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
||||
}
|
||||
@@ -496,19 +524,26 @@ export class PassengerAuthService {
|
||||
}
|
||||
|
||||
async resetUserPassword(id: string, tempPassword: string) {
|
||||
await this.writeActiveCredential(id, tempPassword);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the user's active credential. The IAM keeps credential history and relies on
|
||||
* exactly one row per user having `is_active = true`, so the old row is deactivated in the
|
||||
* same call rather than deleted.
|
||||
*/
|
||||
private async writeActiveCredential(userId: string, password: string): Promise<void> {
|
||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const passwordHash = await hashPassword(tempPassword);
|
||||
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
|
||||
const passwordHash = await hashPassword(password);
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||
[id],
|
||||
[userId],
|
||||
);
|
||||
// Insert new active credential
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||
[id, passwordHash],
|
||||
[userId, passwordHash],
|
||||
);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||
@@ -554,15 +589,45 @@ export class PassengerAuthService {
|
||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const u = users[0];
|
||||
|
||||
await this.consumeSetupOtp(u.id, otp, 'Invalid phone number or OTP');
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...u, verified_by: 'fayda' },
|
||||
'fayda-otp-setup',
|
||||
);
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies and burns a one-time code from `iam.user_verifications`.
|
||||
*
|
||||
* Both password-setup entry points land here: `set-password` codes come from
|
||||
* `password-setup/request`, `verify-phone-number` codes from `POST /auth/register`. Accepting
|
||||
* both is what lets a single screen finish the "existing account with no password" branch and
|
||||
* the "brand new signup" branch.
|
||||
*
|
||||
* Codes are argon2-hashed at rest, so this is a verify rather than an equality check. The
|
||||
* attempt counter is incremented *before* the comparison so a crash mid-verify still costs an
|
||||
* attempt, and the code is burned on the 6th try.
|
||||
*/
|
||||
private async consumeSetupOtp(
|
||||
userId: string,
|
||||
otp: string,
|
||||
failureMessage = 'Invalid or expired code',
|
||||
): Promise<void> {
|
||||
const verifications = await this.dataSource.query<{
|
||||
id: string; verification_code: string; attempt_count: number;
|
||||
}[]>(
|
||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
||||
WHERE user_id = $1
|
||||
AND otp_type IN ('set-password', 'verify-phone-number')
|
||||
AND "isUsed" = false
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[u.id],
|
||||
[userId],
|
||||
);
|
||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!verifications.length) throw new UnauthorizedException(failureMessage);
|
||||
const v = verifications[0];
|
||||
|
||||
if (v.attempt_count >= 5) {
|
||||
@@ -578,12 +643,24 @@ export class PassengerAuthService {
|
||||
|
||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const valid = await verifyPassword(otp, v.verification_code);
|
||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!valid) throw new UnauthorizedException(failureMessage);
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts (or refreshes) an `iam.sessions` row and mints the token pair for it. The JWT payload
|
||||
* is only the session id — `JwtGuard` resolves everything else from the table.
|
||||
*
|
||||
* `device` participates in a unique constraint on `(user_id, device)`, so each flow passes its
|
||||
* own value and none of them clobbers a session another flow established.
|
||||
*/
|
||||
private async mintSession(
|
||||
u: IamAuthRow,
|
||||
device: string,
|
||||
): Promise<{ token: string; refreshToken: string }> {
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
@@ -604,19 +681,183 @@ export class PassengerAuthService {
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $4)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
||||
[u.email ?? '', device, JSON.stringify(userInfo), u.id],
|
||||
);
|
||||
|
||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||
const token = generateToken({ id: sessions[0].id });
|
||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
||||
return {
|
||||
token: generateToken({ id: sessions[0].id }),
|
||||
refreshToken: generateRefreshToken({ id: sessions[0].id }),
|
||||
};
|
||||
}
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
/**
|
||||
* Resolves the single sign-in identifier field to an `iam.users` row.
|
||||
*
|
||||
* A phone number reaches us in three interchangeable shapes (`+2519…`, `2519…`, `09…`)
|
||||
* depending on whether the account was created by IAM signup, a guest booking or Fayda, so
|
||||
* matching on one canonical form silently misses. `normalizePhoneVariants` produces every
|
||||
* shape and the query matches any of them.
|
||||
*
|
||||
* `ORDER BY has_set_password DESC` makes a fully-registered account win over a leftover
|
||||
* pending row that shares the same phone — otherwise a passenger with an abandoned signup
|
||||
* would be pushed into password setup for an account they already finished.
|
||||
*/
|
||||
private async findUserByIdentifier(identifier: string): Promise<IamAuthRow | null> {
|
||||
const resolved = resolveIdentifier(identifier);
|
||||
if (!resolved.email && resolved.phoneVariants.length === 0) return null;
|
||||
|
||||
const rows = await this.dataSource.query<IamAuthRow[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password, verified_by
|
||||
FROM iam.users
|
||||
WHERE ($1::text IS NOT NULL AND lower(email) = $1)
|
||||
OR phone_number = ANY($2::text[])
|
||||
ORDER BY has_set_password DESC
|
||||
LIMIT 1`,
|
||||
[resolved.email, resolved.phoneVariants],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in: decide which of the three branches the portal should render.
|
||||
*
|
||||
* This deliberately reports whether an account exists — the whole point of the flow is that the
|
||||
* passenger stops guessing — so it is a user-enumeration oracle by design. `POST
|
||||
* /v1/auth/forgot-password` already leaks the same fact by throwing `user_not_found`; the
|
||||
* mitigation here is the throttle on this controller, not secrecy. Nothing identifying is
|
||||
* returned: no email, no user id, and the phone only ever masked.
|
||||
*/
|
||||
async lookupIdentifier(identifier: string): Promise<{
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
}> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user) return { status: 'NOT_FOUND' };
|
||||
if (user.has_set_password) return { status: 'PASSWORD' };
|
||||
|
||||
return {
|
||||
status: 'NEEDS_PASSWORD_SETUP',
|
||||
method: user.verified_by === 'fayda' ? 'fayda' : 'pending',
|
||||
maskedPhone: user.phone_number ? maskPhone(user.phone_number) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the SMS code that lets an account with no password set one. Covers both Fayda-created
|
||||
* accounts and abandoned registrations — the distinction only changes the copy the portal
|
||||
* shows, not what happens here.
|
||||
*
|
||||
* Always resolves `{ sent: true }`. Returning a real result would make this a cheaper
|
||||
* enumeration oracle than `lookupIdentifier`, which at least sits behind the same throttle.
|
||||
*/
|
||||
async requestPasswordSetup(identifier: string, req: any): Promise<{ sent: boolean }> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user || user.has_set_password) return { sent: true };
|
||||
|
||||
if (!user.phone_number) {
|
||||
// OTP delivery is SMS + in-app only; there is no email channel. Every account-creation
|
||||
// path requires a phone, so this should be unreachable — log it rather than fail silently.
|
||||
this.logger.warn(
|
||||
`requestPasswordSetup: user ${user.id} has no phone number — no channel to send a code on`,
|
||||
);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
try {
|
||||
await iamAuthService.generateVerificationCode({
|
||||
// Both fields must match the stored row exactly: the IAM looks the user up with
|
||||
// `where: { phoneNumber, email }`, which is AND, not OR. Passing the values we just
|
||||
// read back guarantees the match — including a null email, which TypeORM renders as
|
||||
// `IS NULL` and which coercing to '' would break.
|
||||
email: user.email as string,
|
||||
phoneNumber: user.phone_number,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[PassengerAuthService] password setup code failed for user ${user.id}`,
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems the code, writes the password, and returns a session — the passenger lands signed in
|
||||
* rather than being bounced back to the login form.
|
||||
*
|
||||
* Returns the same shape as `login()` so the portal can store the result through one code path.
|
||||
*/
|
||||
async completePasswordSetup(dto: PasswordSetupCompleteDto): Promise<{
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
}> {
|
||||
if (dto.newPassword !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Passwords do not match');
|
||||
}
|
||||
|
||||
const user = await this.findUserByIdentifier(dto.identifier);
|
||||
// Same message whether the account is missing or the code is wrong: the branch was already
|
||||
// disclosed by `lookupIdentifier`, but there is no reason to re-confirm it on every attempt.
|
||||
if (!user) throw new UnauthorizedException('Invalid or expired code');
|
||||
if (user.has_set_password) {
|
||||
throw new BadRequestException(
|
||||
'This account already has a password. Sign in with it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.consumeSetupOtp(user.id, dto.otp);
|
||||
await this.writeActiveCredential(user.id, dto.newPassword);
|
||||
|
||||
// Redeeming the code proves ownership of the phone, which is what promotes a Fayda-created
|
||||
// `submitted` row or a pending signup to a usable account.
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users
|
||||
SET has_set_password = true,
|
||||
status = 'accepted',
|
||||
is_active = true,
|
||||
is_phone_number_verified = true,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...user, has_set_password: true },
|
||||
'password-setup',
|
||||
);
|
||||
|
||||
let passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!passenger) {
|
||||
const result = await this.provisionPassengerSatellite({
|
||||
iamUserId: user.id,
|
||||
auditAction: 'USER_AUTO_PROVISIONED',
|
||||
});
|
||||
passenger = { id: result.passengerId };
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
iamUserId: user.id,
|
||||
email: user.email,
|
||||
passengerId: passenger.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private standardizePhone(phone: string): string {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
@@ -44,6 +45,7 @@ export class BookingsController {
|
||||
constructor(
|
||||
private service: BookingsService,
|
||||
private guestService: GuestBookingService,
|
||||
private seatsService: SeatsService,
|
||||
) {}
|
||||
|
||||
@Get("my")
|
||||
@@ -358,6 +360,33 @@ export class BookingsController {
|
||||
return this.guestService.createGuestBooking(dto, req);
|
||||
}
|
||||
|
||||
@Post("group")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
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. 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, 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: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
|
||||
} catch (err) {
|
||||
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
||||
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
@@ -47,39 +48,6 @@ function resolvePackageRoundTripTotal(
|
||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
|
||||
@@ -168,6 +168,15 @@ 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.' })
|
||||
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'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 ' +
|
||||
'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' +
|
||||
'(overwriting the real name) or, once configured, would reject the whole booking on a non-match.',
|
||||
})
|
||||
@IsOptional() @IsBoolean() skipIdentityVerification?: boolean;
|
||||
}
|
||||
|
||||
export class SavedPassengerProfileDto {
|
||||
|
||||
@@ -196,7 +196,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(
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,10 @@ export class LogExcessBaggageDto {
|
||||
@IsOptional() @IsString() bookingReference?: string;
|
||||
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
|
||||
@IsOptional() @IsString() agentId?: string;
|
||||
@ApiPropertyOptional({ example: '+251911223344', description: 'Override the phone the payment link SMS should go to. Defaults to the booking contact phone.' })
|
||||
@IsOptional() @IsString() contactPhone?: string;
|
||||
@ApiPropertyOptional({ example: 'passenger@example.com', description: 'Override the email the payment link should also be sent to. Defaults to the booking contact email.' })
|
||||
@IsOptional() @IsString() contactEmail?: string;
|
||||
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
|
||||
@IsInt() @IsPositive() excessWeightKg: number;
|
||||
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })
|
||||
|
||||
@@ -115,8 +115,8 @@ export class ExcessBaggageService {
|
||||
|
||||
const totalMinor = feePerKgMinor * dto.excessWeightKg;
|
||||
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
||||
const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
|
||||
const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
|
||||
const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
|
||||
const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
|
||||
|
||||
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
|
||||
const paidAt = dto.collectCash ? new Date() : null;
|
||||
|
||||
@@ -229,10 +229,11 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Get('coaches/utilization')
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' })
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
|
||||
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||
getCoachUtilization() {
|
||||
return this.service.getCoachUtilization();
|
||||
getCoachUtilization(@Query('scheduleId') scheduleId?: string) {
|
||||
return this.service.getCoachUtilization(scheduleId);
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
|
||||
@@ -822,12 +822,35 @@ export class FleetService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCoachUtilization() {
|
||||
async getCoachUtilization(scheduleId?: string) {
|
||||
const where = scheduleId ? { scheduleId } : {};
|
||||
|
||||
const coaches = await this.prisma.coach.findMany({
|
||||
where: scheduleId
|
||||
? {
|
||||
assignments: {
|
||||
some: { scheduleId },
|
||||
},
|
||||
}
|
||||
: {},
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true, status: true } },
|
||||
seats: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
bookingSeats: {
|
||||
where,
|
||||
select: { id: true },
|
||||
},
|
||||
blocks: {
|
||||
where,
|
||||
select: { id: true, reasonCategory: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
where,
|
||||
include: {
|
||||
schedule: {
|
||||
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
||||
@@ -842,10 +865,12 @@ export class FleetService {
|
||||
|
||||
return coaches.map((coach) => {
|
||||
const totalSeats = coach.seats.length;
|
||||
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
|
||||
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
|
||||
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length;
|
||||
const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length;
|
||||
const availableSeats = scheduleId
|
||||
? Math.max(totalSeats - bookedSeats - blockedSeats - maintenanceSeats, 0)
|
||||
: coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const totalAssignments = coach.assignments.length;
|
||||
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
|
||||
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ class CreateSupplementaryChargeDto {
|
||||
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
|
||||
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
|
||||
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
|
||||
@ApiPropertyOptional({ description: 'Override the phone the payment link SMS should go to. Falls back to the booking contact phone.', example: '+251911223344' }) @IsOptional() @IsString() contactPhone?: string;
|
||||
@ApiPropertyOptional({ description: 'Override the email the payment link should also be sent to. Falls back to the booking contact email.', example: 'passenger@example.com' }) @IsOptional() @IsString() contactEmail?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ export class SupplementaryChargesService {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
createdBy: string;
|
||||
/** Overrides the default 72h link lifetime (a reschedule charge must die with its seat hold). */
|
||||
expiresAt?: Date;
|
||||
@@ -80,8 +82,8 @@ export class SupplementaryChargesService {
|
||||
},
|
||||
});
|
||||
|
||||
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
|
||||
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
|
||||
const phone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
|
||||
const email = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
|
||||
await this.sendLink(charge, booking.bookingRef, phone, email);
|
||||
|
||||
await this.auditService.log({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -200,7 +200,7 @@ export class SchedulesService {
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
orderBy: { departureAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {} },
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
|
||||
import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
|
||||
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
@@ -186,6 +186,33 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
return this.service.holdSeats(dto);
|
||||
}
|
||||
|
||||
@Post("auto-assign-hold")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@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 (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" })
|
||||
@ApiResponse({ status: 409, description: "Not enough seats available in the requested class" })
|
||||
autoAssignHold(@Body() dto: AutoAssignHoldDto) {
|
||||
const passengerCount = dto.adultCount + (dto.childCount ?? 0);
|
||||
return this.service.autoAssignAndHold(
|
||||
dto.scheduleId,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.seatClassName,
|
||||
passengerCount,
|
||||
dto.journeyDirection,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("hold/:holdId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -49,6 +49,33 @@ export class HoldSeatsDto {
|
||||
passengers: PassengerSeatDto[];
|
||||
}
|
||||
|
||||
export class AutoAssignHoldDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name to auto-assign from — must match a class returned by POST /search for this schedule.' })
|
||||
@IsString() seatClassName: string;
|
||||
|
||||
@ApiProperty({ example: 4, minimum: 1, description: 'Number of adult passengers to assign seats for.' })
|
||||
@IsInt() @Min(0) adultCount: number;
|
||||
|
||||
@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 {
|
||||
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@@ -18,6 +18,10 @@ describe('SeatsService - Auto Assign', () => {
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
seatClass: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
tripStopTime: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
@@ -72,6 +76,14 @@ describe('SeatsService - Auto Assign', () => {
|
||||
]);
|
||||
mockPrisma.seatBlock.findMany.mockResolvedValue([]);
|
||||
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-1',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Hard Seat Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: null },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('assertNoRouteSeatConflict', () => {
|
||||
@@ -114,25 +126,27 @@ describe('SeatsService - Auto Assign', () => {
|
||||
});
|
||||
|
||||
describe('autoAssignSeats', () => {
|
||||
it('should assign contiguous seats in same row', async () => {
|
||||
it('should assign seats in ascending seat-number order (not row/insertion order)', async () => {
|
||||
// Deliberately out of order and non-contiguous-by-row to prove the sort is driven by
|
||||
// seatNumber, not by the order seats came back from the query or their row grouping.
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' },
|
||||
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' },
|
||||
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' },
|
||||
{ id: 'seat-3', seatNumber: '3', coachId: 'coach-1', row: 2, col: 'A' },
|
||||
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-2', seatNumber: '2', coachId: 'coach-1', row: 1, col: 'B' },
|
||||
{ id: 'seat-10', seatNumber: '10', coachId: 'coach-1', row: 3, col: 'A' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Numeric order (1, 2) — a lexicographic sort would have put '10' before '2'.
|
||||
expect(result).toEqual(['seat-1', 'seat-2']);
|
||||
});
|
||||
|
||||
it('should throw error if not enough seats available', async () => {
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
@@ -140,22 +154,9 @@ describe('SeatsService - Auto Assign', () => {
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('should respect eligibility filter', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
|
||||
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should assign single seat', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
@@ -164,5 +165,75 @@ describe('SeatsService - Auto Assign', () => {
|
||||
|
||||
expect(result).toEqual(['seat-1']);
|
||||
});
|
||||
|
||||
it('should fill Lower, then Middle, then Upper — a fixed physical order, not fare order', async () => {
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-hbc',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Hard Berth Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: 'UPPER' },
|
||||
{ bedPosition: 'MIDDLE' },
|
||||
{ bedPosition: 'LOWER' },
|
||||
]);
|
||||
// Upper is the cheapest tier in the seed data (4000 vs 5500 Middle vs 6000 Lower) — this
|
||||
// deliberately picks seats so a fare-order algorithm and a lower-first algorithm disagree.
|
||||
const mockSeats = [
|
||||
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
|
||||
{ id: 'upper-2', seatNumber: '17', coachId: 'coach-1', row: 4, col: 'B', bedPosition: 'UPPER' },
|
||||
{ id: 'middle-1', seatNumber: '11', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'MIDDLE' },
|
||||
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
|
||||
{ id: 'lower-2', seatNumber: '7', coachId: 'coach-1', row: 2, col: 'B', bedPosition: 'LOWER' },
|
||||
];
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 3, 'Economy Bed Upper (Intl)');
|
||||
|
||||
// Both Lower seats first, then spill into Middle — Upper is untouched even though it's cheaper.
|
||||
expect(result).toEqual(['lower-1', 'lower-2', 'middle-1']);
|
||||
});
|
||||
|
||||
it('should count all fare tiers toward availability, not just one tier', async () => {
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-hbc',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Hard Berth Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: 'UPPER' },
|
||||
{ bedPosition: 'LOWER' },
|
||||
]);
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
|
||||
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
|
||||
]);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should match bed-tier seats regardless of case (SeatClass.bedPosition is seeded uppercase, Seat.bedPosition is stored lowercase in production data)', async () => {
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-sbc',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Soft Berth Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: 'UPPER' },
|
||||
{ bedPosition: 'LOWER' },
|
||||
]);
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'upper' },
|
||||
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'lower' },
|
||||
]);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Lower fills before Upper regardless of case.
|
||||
expect(result).toEqual(['lower-1', 'upper-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
|
||||
import { ActingUser } from '../../common/acting-user';
|
||||
@@ -826,14 +827,56 @@ export class SeatsService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: { assignments: { some: { scheduleId } } },
|
||||
seatNumber: { not: '' },
|
||||
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
// Resolve the requested class to its actual SeatClass row, then pool seats across every
|
||||
// fare tier (bed position) that shares its coachTypeId + nationalityType. "Economy"/"VIP"
|
||||
// are coach categories, not one physical seat pool — Upper/Middle/Lower berths are
|
||||
// genuinely different seats priced differently — but the whole group is billed one uniform
|
||||
// rate (the cheapest tier, which is what callers pass as seatClassName; see
|
||||
// bookings.controller's group endpoint). A coach type with no bed split (e.g. Economy
|
||||
// Regular) has exactly one tier, so this collapses to plain seat-number order for it.
|
||||
const seatClass = await this.prisma.seatClass.findFirst({
|
||||
where: { name: seatClassName },
|
||||
select: { coachTypeId: true, nationalityType: true, coachType: { select: { name: true } } },
|
||||
});
|
||||
if (!seatClass) throw new NotFoundException(`Seat class "${seatClassName}" not found`);
|
||||
|
||||
const siblingClasses = await this.prisma.seatClass.findMany({
|
||||
where: { coachTypeId: seatClass.coachTypeId, nationalityType: seatClass.nationalityType },
|
||||
select: { bedPosition: true },
|
||||
});
|
||||
// SeatClass.bedPosition is seeded uppercase ('UPPER'), but Seat.bedPosition is stored
|
||||
// lowercase ('upper') — normalize both sides or every bed-tier seat silently fails to match.
|
||||
const validBedPositions = new Set(siblingClasses.map((sc) => (sc.bedPosition ?? '').toLowerCase()));
|
||||
// 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 allSeatsOnSchedule = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: {
|
||||
coachTypeId: seatClass.coachTypeId,
|
||||
assignments: { some: { scheduleId } },
|
||||
},
|
||||
seatNumber: { not: '' },
|
||||
// Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this
|
||||
// method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at
|
||||
// the query level regardless of an `as any` cast, so that clause would throw at
|
||||
// runtime the moment this method was ever actually called. Maintenance-blocked seats
|
||||
// are still excluded below via the schedule-scoped SeatBlock check.
|
||||
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }],
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }],
|
||||
});
|
||||
|
||||
// Lower → Middle → Upper, then ascending seat number within a tier (seatNumber is a string
|
||||
// column, so DB/lexicographic ordering would sort "10" before "2" — compare numerically here).
|
||||
const seats = allSeatsOnSchedule
|
||||
.filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0)
|
||||
- (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0);
|
||||
if (tierDiff !== 0) return tierDiff;
|
||||
return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10);
|
||||
});
|
||||
|
||||
const allSeatIds = seats.map(s => s.id);
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
@@ -858,30 +901,44 @@ export class SeatsService {
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
|
||||
|
||||
if (availableSeats.length < count) {
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`);
|
||||
}
|
||||
|
||||
const assigned = this.findContiguousSeats(availableSeats, count);
|
||||
return assigned.map((s) => s.id);
|
||||
// availableSeats is already ordered lower→middle→upper, ascending seat number within a
|
||||
// tier — take the first `count` in that order, spilling into the next tier once one runs out.
|
||||
return availableSeats.slice(0, count).map((s) => s.id);
|
||||
}
|
||||
|
||||
private findContiguousSeats(seats: any[], count: number): any[] {
|
||||
if (count === 1) return [seats[0]];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const seat of seats) {
|
||||
const key = `${seat.coachId}-${seat.row}`;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(seat);
|
||||
}
|
||||
|
||||
for (const rowSeats of grouped.values()) {
|
||||
if (rowSeats.length >= count) {
|
||||
return rowSeats.slice(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
return seats.slice(0, count);
|
||||
/**
|
||||
* Auto-assigns `count` seats of `seatClassName` and immediately holds them in one request,
|
||||
* for callers (like bulk/group booking) that must never show an assignment the caller could
|
||||
* lose to a race before confirming it. Reuses `holdSeats` as-is — a single hold already
|
||||
* supports many seats/passengers in one row (see `SeatHold.seatIds: String[]`), so this is
|
||||
* pure orchestration, not a new hold mechanism.
|
||||
*/
|
||||
async autoAssignAndHold(
|
||||
scheduleId: string,
|
||||
originStationId: string,
|
||||
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
|
||||
// "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
|
||||
// same train within the hold TTL), tripping holdSeats' "passenger already holds a seat on
|
||||
// this journey leg" conflict check for two entirely unrelated bookings.
|
||||
const attemptId = randomUUID();
|
||||
const passengers = seatIds.map((seatId, i) => ({ passengerId: `group-${attemptId}-${i + 1}`, seatId }));
|
||||
return this.holdSeats({
|
||||
scheduleId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
journeyDirection,
|
||||
passengers,
|
||||
} as HoldSeatsDto);
|
||||
}
|
||||
|
||||
async exportSeatsCSV(scheduleId: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user