Boarding, payment methods, journey direction on seat hold, and more updates

This commit is contained in:
Stephanos A
2026-06-29 08:44:38 +03:00
parent 81ae99cee3
commit c6e56d1c4f
65 changed files with 6437 additions and 1425 deletions

View File

@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
@@ -24,6 +24,7 @@ import { PrismaModule } from './common/prisma.module';
import { AuditModule } from './common/audit.module';
import { I18nModule } from './common/i18n/i18n.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
import { DeleteExceptionFilter } from './common/exceptions/delete-exception.filter';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
import iamDatabaseConfig from './config/iam-database.config';
@@ -65,6 +66,7 @@ import { PackagesModule } from './modules/packages/packages.module';
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
@Module({
imports: [
@@ -134,9 +136,11 @@ import { TasksModule } from './modules/tasks/tasks.module';
ExcessBaggageModule,
HealthModule,
TasksModule,
ConfigurableFareModule,
],
providers: [
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },
DynamicThrottlerGuard,
EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder,

View File

@@ -0,0 +1,41 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { DeleteOperationException } from './delete-operation.exception';
@Catch(DeleteOperationException, HttpException)
export class DeleteExceptionFilter implements ExceptionFilter {
catch(exception: DeleteOperationException | HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus?.() || HttpStatus.BAD_REQUEST;
if (exception instanceof DeleteOperationException) {
// Format the response specifically for delete operations
response.status(status).json({
statusCode: status,
error: 'Delete Operation Failed',
message: exception.message,
timestamp: new Date().toISOString(),
type: 'DELETE_CONSTRAINT_VIOLATION',
userFriendly: true,
details: {
canRetry: true,
action: 'RESOLVE_DEPENDENCIES',
hint: 'Please resolve the listed dependencies and try again.'
}
});
} else if (exception instanceof HttpException) {
// Handle other HTTP exceptions normally
const exceptionResponse = exception.getResponse();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
...(typeof exceptionResponse === 'object'
? exceptionResponse
: { message: exceptionResponse }
)
});
}
}
}

View File

@@ -0,0 +1,85 @@
import { BadRequestException } from '@nestjs/common';
export interface DeleteConstraint {
entityName: string;
count: number;
action: 'delete' | 'reassign' | 'cancel' | 'complete';
}
export class DeleteOperationException extends BadRequestException {
constructor(
entityType: string,
entityName: string,
constraints: DeleteConstraint[]
) {
const message = DeleteOperationException.buildUserFriendlyMessage(
entityType,
entityName,
constraints
);
super(message);
}
private static buildUserFriendlyMessage(
entityType: string,
entityName: string,
constraints: DeleteConstraint[]
): string {
const baseMessage = `Cannot delete ${entityType.toLowerCase()} "${entityName}".`;
if (constraints.length === 0) {
return `${baseMessage} Unknown constraint violation.`;
}
const constraintMessages = constraints.map(constraint => {
const { entityName: constraintEntity, count, action } = constraint;
const entityDisplayName = count === 1
? constraintEntity.toLowerCase()
: `${constraintEntity.toLowerCase()}s`;
const actionText = this.getActionText(action, count);
return `${count} ${entityDisplayName} ${count === 1 ? 'is' : 'are'} still ${this.getStatusText(constraintEntity)}. Please ${actionText} first.`;
});
return [
baseMessage,
'',
'The following dependencies must be resolved:',
...constraintMessages,
'',
'Once all dependencies are resolved, you can retry the deletion.'
].join('\n');
}
private static getActionText(action: string, count: number): string {
const actions: Record<string, string> = {
delete: count === 1 ? 'delete it' : 'delete them',
reassign: count === 1 ? 'reassign it' : 'reassign them',
cancel: count === 1 ? 'cancel it' : 'cancel them',
complete: count === 1 ? 'complete it' : 'complete them'
};
return actions[action] || (count === 1 ? 'resolve it' : 'resolve them');
}
private static getStatusText(entityName: string): string {
const statusTexts: Record<string, string> = {
booking: 'active',
schedule: 'in use',
coach: 'assigned',
seat: 'occupied or blocked',
'seat class': 'in use by fare rules',
'coach type': 'in use by coaches or seat classes',
train: 'scheduled',
ticket: 'issued',
'payment record': 'linked',
'fare rule': 'active',
route: 'in use by schedules',
passenger: 'active with bookings or accounts',
promotion: 'active',
station: 'in use by routes'
};
return statusTexts[entityName.toLowerCase()] || 'in use';
}
}

View File

@@ -0,0 +1,2 @@
export { DeleteOperationException, DeleteConstraint } from './delete-operation.exception';
export { DeleteExceptionFilter } from './delete-exception.filter';

View File

@@ -0,0 +1,33 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class IamGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No IAM token provided');
}
const token = authHeader.substring(7);
// TODO: Implement actual IAM token validation
// For now, just check if token exists
if (!token) {
throw new UnauthorizedException('Invalid IAM token');
}
// Add user info to request for downstream usage
request.user = {
id: 'iam-user-id',
roles: ['AGENT'],
permissions: []
};
return true;
}
}

View File

@@ -0,0 +1,137 @@
/**
* Timezone Utility for Ethiopian Railway
*
* All dates/times in the system are stored and handled in Ethiopian Time (EAT - UTC+3).
* This utility ensures consistent date handling across the application.
*
* IMPORTANT: The application timezone is set to 'Africa/Addis_Ababa' in main.ts
*/
/**
* Parse a date string or Date object ensuring it's treated as Ethiopian time (EAT - UTC+3)
*
* @param dateInput - ISO string, date string, or Date object
* @returns Date object in Ethiopian time
*
* @example
* parseEthiopianTime('2026-06-15T08:00:00') // Treats as 08:00 EAT, not UTC
* parseEthiopianTime('2026-06-15') // Treats as midnight EAT
*/
export function parseEthiopianTime(dateInput: string | Date): Date {
if (dateInput instanceof Date) {
return dateInput;
}
// Parse as local time (EAT) since TZ is set to Africa/Addis_Ababa
return new Date(dateInput);
}
/**
* Get the start of day (00:00:00) in Ethiopian time
*
* @param date - Date object or date string
* @returns Date object set to midnight EAT
*/
export function startOfDayEAT(date: Date | string): Date {
const d = typeof date === 'string' ? new Date(date) : new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Get the end of day (23:59:59.999) in Ethiopian time
*
* @param date - Date object or date string
* @returns Date object set to end of day EAT
*/
export function endOfDayEAT(date: Date | string): Date {
const d = typeof date === 'string' ? new Date(date) : new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}
/**
* Get the start of the next day in Ethiopian time
*
* @param date - Date object or date string
* @returns Date object set to midnight of next day EAT
*/
export function startOfNextDayEAT(date: Date | string): Date {
const d = startOfDayEAT(date);
d.setDate(d.getDate() + 1);
return d;
}
/**
* Format a date for display in Ethiopian time
*
* @param date - Date object
* @param options - Intl.DateTimeFormatOptions
* @returns Formatted date string
*/
export function formatEthiopianTime(
date: Date,
options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}
): string {
return new Intl.DateTimeFormat('en-ET', {
...options,
timeZone: 'Africa/Addis_Ababa',
}).format(date);
}
/**
* Add minutes to a date
*
* @param date - Date object
* @param minutes - Number of minutes to add
* @returns New Date object
*/
export function addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60_000);
}
/**
* Add hours to a date
*
* @param date - Date object
* @param hours - Number of hours to add
* @returns New Date object
*/
export function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60_000);
}
/**
* Add days to a date
*
* @param date - Date object
* @param days - Number of days to add
* @returns New Date object
*/
export function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* Check if two dates are on the same day (Ethiopian time)
*
* @param date1 - First date
* @param date2 - Second date
* @returns true if both dates are on the same calendar day in EAT
*/
export function isSameDayEAT(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}

View File

@@ -11,6 +11,9 @@ import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
// Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations
process.env.TZ = 'Africa/Addis_Ababa';
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.
@@ -44,6 +47,7 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Latest Updates
- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Baggage, Packages, and comprehensive CRUD operations across all entities.
- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting.
- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt.
- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers.
@@ -313,10 +317,9 @@ Payment providers send notifications to:
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
.addTag("Config", "System-wide configuration management including feature flags, maintenance modes, and operational parameters. IAM-protected endpoints for administrative control.")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Passenger Auth", "Passenger registration, login, OTP, password reset, Fayda password setup, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
.addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency")
@@ -328,9 +331,10 @@ Payment providers send notifications to:
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management. Boarding pass email+SMS sent automatically on gate validation.")
.addTag("Configurable Fares", "Advanced fare management system with flexible configurations, rate rules, components, age-based pricing, and migration tools. Supports nationality-based rates, bed position pricing, and dynamic component calculations.")
.addTag("Passenger Auth", "JWT-authenticated passenger login, profile, and session management")
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles. Rate limited: 20 req/min.")
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds. Rate limited: 20 req/min.")
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation. Exempt from rate limiting.")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
@@ -342,7 +346,6 @@ Payment providers send notifications to:
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), audit trails. Boarding pass email+SMS sent automatically on every successful validation.")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger. Rate limited: 20 req/min.")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")

View File

@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -277,6 +278,16 @@ export class BookingsService {
return {
items: items.map(booking => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
// Build passenger list with categories
const passengerDetails = booking.seats.map((s: any) => ({
name: s.passengerName,
category: s.passengerCategory // 'ADULT' or 'CHILD'
}));
// Get unique names with their categories
const uniquePassengers = Array.from(
new Map(passengerDetails.map(p => [p.name, p])).values()
);
return {
id: booking.id,
bookingRef: booking.bookingRef,
@@ -296,6 +307,7 @@ export class BookingsService {
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers, // Include category info
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
@@ -359,6 +371,24 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
}
// Track which child gets free fare (first child encountered)
let freeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor;
} else {
// Child: first child is free, subsequent children pay full fare
if (!freeChildUsed) {
fareMinor = 0;
freeChildUsed = true;
} else {
fareMinor = fareCalculation.baseFareMinor;
}
}
return { ...p, fareMinor };
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -372,7 +402,7 @@ export class BookingsService {
displayCurrency,
displayTotalMinor,
seats: {
create: passengersData.map(p => ({
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
@@ -382,7 +412,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
fareMinor: p.fareMinor,
displayCurrency
}))
}
@@ -462,6 +492,37 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
// Track which child gets free fare for outbound and return legs
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor;
} else {
// Child fare for outbound
if (!outboundFreeChildUsed) {
outboundFareMinor = 0;
outboundFreeChildUsed = true;
} else {
outboundFareMinor = outboundFare.baseFareMinor;
}
// Child fare for return
if (!returnFreeChildUsed) {
returnFareMinor = 0;
returnFreeChildUsed = true;
} else {
returnFareMinor = returnFare.baseFareMinor;
}
}
return { ...p, outboundFareMinor, returnFareMinor };
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -482,7 +543,7 @@ export class BookingsService {
returnLegStatus: 'NEITHER_USED',
seats: {
create: [
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -494,10 +555,10 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
fareMinor: p.outboundFareMinor,
displayCurrency,
})),
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
@@ -509,7 +570,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
@@ -606,6 +667,37 @@ export class BookingsService {
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Track which child gets free fare for leg1 and leg2
let leg1FreeChildUsed = false;
let leg2FreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let leg1FareMinor: number;
let leg2FareMinor: number;
if (p.category === PassengerCategory.ADULT) {
leg1FareMinor = leg1Fare.baseFareMinor;
leg2FareMinor = leg2Fare.baseFareMinor;
} else {
// Child fare for leg1
if (!leg1FreeChildUsed) {
leg1FareMinor = 0;
leg1FreeChildUsed = true;
} else {
leg1FareMinor = leg1Fare.baseFareMinor;
}
// Child fare for leg2
if (!leg2FreeChildUsed) {
leg2FareMinor = 0;
leg2FreeChildUsed = true;
} else {
leg2FareMinor = leg2Fare.baseFareMinor;
}
}
return { ...p, leg1FareMinor, leg2FareMinor };
});
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
@@ -625,7 +717,7 @@ export class BookingsService {
leg2SeatClassId,
seats: {
create: [
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -637,10 +729,10 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
fareMinor: p.leg1FareMinor,
displayCurrency,
})),
...passengersData.map(p => ({
...passengersWithFares.map(p => ({
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
leg: 2,
scheduleId: dto.leg2ScheduleId,
@@ -652,7 +744,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
fareMinor: p.leg2FareMinor,
displayCurrency,
})),
],
@@ -769,7 +861,32 @@ export class BookingsService {
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited<ReturnType<BookingsService['calculateFare']>>) => ({
// Track which child gets free fare for all 4 legs
let obL1FreeChildUsed = false;
let obL2FreeChildUsed = false;
let retL1FreeChildUsed = false;
let retL2FreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let obL1FareMinor: number, obL2FareMinor: number, retL1FareMinor: number, retL2FareMinor: number;
if (p.category === PassengerCategory.ADULT) {
obL1FareMinor = obL1Fare.baseFareMinor;
obL2FareMinor = obL2Fare.baseFareMinor;
retL1FareMinor = retL1Fare.baseFareMinor;
retL2FareMinor = retL2Fare.baseFareMinor;
} else {
// Child fares for each leg
obL1FareMinor = !obL1FreeChildUsed ? (obL1FreeChildUsed = true, 0) : obL1Fare.baseFareMinor;
obL2FareMinor = !obL2FreeChildUsed ? (obL2FreeChildUsed = true, 0) : obL2Fare.baseFareMinor;
retL1FareMinor = !retL1FreeChildUsed ? (retL1FreeChildUsed = true, 0) : retL1Fare.baseFareMinor;
retL2FareMinor = !retL2FreeChildUsed ? (retL2FreeChildUsed = true, 0) : retL2Fare.baseFareMinor;
}
return { ...p, obL1FareMinor, obL2FareMinor, retL1FareMinor, retL2FareMinor };
});
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fareMinor: number) => ({
seat: { connect: { id: seatId } },
leg,
scheduleId,
@@ -781,7 +898,7 @@ export class BookingsService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
fareMinor,
displayCurrency,
});
@@ -811,13 +928,13 @@ export class BookingsService {
seats: {
create: [
// Outbound leg-1 (sequence 1)
...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
...passengersWithFares.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, p.obL1FareMinor)),
// Outbound leg-2 (sequence 2)
...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
...passengersWithFares.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, p.obL2FareMinor)),
// Return leg-1 (sequence 3)
...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
...passengersWithFares.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, p.retL1FareMinor)),
// Return leg-2 (sequence 4)
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
...passengersWithFares.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, p.retL2FareMinor)),
],
},
} as any,
@@ -1060,7 +1177,7 @@ export class BookingsService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, ticket: true,
paymentIntent: true, tickets: { take: 1 },
},
});
if (!booking) throw new NotFoundException('Booking not found');
@@ -1077,14 +1194,14 @@ export class BookingsService {
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
schedule: {
id: booking.schedule.id,
trainNumber: booking.schedule.train.number,
trainName: booking.schedule.train.name,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
passengers: booking.seats?.map((bs: any) => ({
passengers: (booking as any).seats?.map((bs: any) => ({
fullName: bs.passengerName,
category: bs.passengerCategory,
leg: bs.leg ?? 1,
@@ -1098,8 +1215,8 @@ export class BookingsService {
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
},
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
};
}
@@ -1153,6 +1270,12 @@ export class BookingsService {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
// Check usage before allowing deletion
const usage = await this.checkBookingUsage(id);
if (usage.isInUse && usage.constraints) {
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
}
await this.seatsService.releaseSeats(booking.id);
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
@@ -1172,15 +1295,16 @@ export class BookingsService {
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
]);
const usage = [];
if (ticketCount > 0) usage.push('Ticket(s)');
if (paymentIntentCount > 0) usage.push('Payment record(s)');
if (modificationsCount > 0) usage.push('Modification history');
if (cancellationCount > 0) usage.push('Cancellation record(s)');
const constraints = [];
if (ticketCount > 0) constraints.push({ entityName: 'ticket', count: ticketCount, action: 'complete' as const });
if (paymentIntentCount > 0) constraints.push({ entityName: 'payment record', count: paymentIntentCount, action: 'complete' as const });
if (modificationsCount > 0) constraints.push({ entityName: 'modification record', count: modificationsCount, action: 'complete' as const });
if (cancellationCount > 0) constraints.push({ entityName: 'cancellation record', count: cancellationCount, action: 'complete' as const });
return {
isInUse: usage.length > 0,
affectedModules: usage,
isInUse: constraints.length > 0,
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
constraints
};
}

View File

@@ -231,6 +231,9 @@ export class GuestBookingService {
},
});
// Save passenger details as traveler profiles
await this.createTravelerProfiles(guestPassengerId, passengersData);
// Confirm seats
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId));
this.eventEmitter.emit('booking.created', { booking });
@@ -443,6 +446,8 @@ export class GuestBookingService {
},
});
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds),
@@ -635,6 +640,8 @@ export class GuestBookingService {
},
});
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
@@ -822,6 +829,8 @@ export class GuestBookingService {
},
});
await this.createTravelerProfiles(guestPassengerId, passengersData);
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
@@ -870,12 +879,44 @@ export class GuestBookingService {
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
}
// Create guest passenger with basic profile
const guestPassenger = await this.prisma.passenger.create({ data: {} });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
}
private async createTravelerProfiles(passengerId: string, passengersData: any[]): Promise<void> {
for (const passenger of passengersData) {
let gender: string | null = null;
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
}
await this.prisma.travelerProfile.create({
data: {
passengerId,
fullName: passenger.passengerName,
gender,
dateOfBirth: passenger.dateOfBirth,
nationalId: passenger.idDocumentType === IdDocumentType.NATIONAL_ID ? passenger.idDocumentNumber : null,
relationship: 'self',
notes: JSON.stringify({
idDocumentType: passenger.idDocumentType,
idDocumentNumber: passenger.idDocumentNumber,
passportNumber: passenger.passportNumber,
passportCountry: passenger.passportCountry,
nationality: passenger.nationality,
phone: passenger.phone,
email: passenger.email,
verifaydaVerified: passenger.verifaydaVerified,
}),
},
});
}
}
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');

View File

@@ -0,0 +1,235 @@
import { Body, Controller, Get, Post, Put, Delete, Param, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { ConfigurableFareService } from './configurable-fare.service';
import {
CreateFareConfigurationDto,
UpdateFareConfigurationDto,
FareTestScenarioDto,
FareCalculationResultDto,
MigrateLegacyDto,
CreateNewFormulaDto,
ToggleFeatureDto
} from './configurable-fare.dto';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
@ApiTags('Configurable Fares')
@Controller('admin/fare-configurations')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class ConfigurableFareController {
constructor(private service: ConfigurableFareService) {}
@Get()
@ApiOperation({ summary: 'List all fare configurations' })
@ApiResponse({ status: 200, description: 'List of all configurations with summary counts' })
async getAllConfigurations() {
return this.service.getAllConfigurations();
}
@Post()
@ApiOperation({ summary: 'Create new fare configuration' })
@ApiResponse({ status: 201, description: 'Configuration created successfully' })
@ApiResponse({ status: 400, description: 'Invalid configuration data' })
async createConfiguration(
@Body() dto: CreateFareConfigurationDto,
@Request() req: any
) {
const createdBy = req.user?.id || req.user?.sub;
return this.service.createConfiguration(dto, createdBy);
}
@Get(':id')
@ApiOperation({ summary: 'Get configuration details' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration details with all rules' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async getConfigurationById(@Param('id') id: string) {
return this.service.getConfigurationById(id);
}
@Put(':id')
@ApiOperation({ summary: 'Update configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration updated successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async updateConfiguration(
@Param('id') id: string,
@Body() dto: UpdateFareConfigurationDto,
@Request() req: any
) {
const updatedBy = req.user?.id || req.user?.sub;
return this.service.updateConfiguration(id, dto, updatedBy);
}
@Post(':id/activate')
@ApiOperation({ summary: 'Activate configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration activated successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async activateConfiguration(@Param('id') id: string, @Request() req: any) {
const activatedBy = req.user?.id || req.user?.sub;
return this.service.activateConfiguration(id, activatedBy);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration deleted successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
@ApiResponse({ status: 409, description: 'Cannot delete active configuration' })
async deleteConfiguration(@Param('id') id: string, @Request() req: any) {
const deletedBy = req.user?.id || req.user?.sub;
return this.service.deleteConfiguration(id, deletedBy);
}
@Post(':id/test')
@ApiOperation({ summary: 'Test fare calculation with configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, type: FareCalculationResultDto, description: 'Fare calculation result' })
@ApiResponse({ status: 400, description: 'Invalid test scenario or missing rate rules' })
async testConfiguration(
@Param('id') id: string,
@Body() scenario: FareTestScenarioDto
): Promise<FareCalculationResultDto> {
return this.service.testConfiguration(id, scenario);
}
@Get(':id/audit')
@ApiOperation({ summary: 'Get configuration audit trail' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Audit trail entries' })
async getAuditTrail(@Param('id') id: string) {
return this.service.getAuditTrail(id);
}
}
@ApiTags('Configurable Fares')
@Controller('admin/fare-migration')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class FareMigrationController {
constructor(private service: ConfigurableFareService) {}
@Post('migrate-legacy')
@ApiOperation({ summary: 'Migrate existing fare rules to configurable system' })
@ApiResponse({ status: 200, description: 'Migration completed successfully' })
@ApiResponse({ status: 400, description: 'Migration failed' })
async migrateLegacySystem(@Body() dto: MigrateLegacyDto) {
return this.service.migrateLegacySystem(dto);
}
@Post('create-new-formula')
@ApiOperation({ summary: 'Create new formula configuration with defaults' })
@ApiResponse({ status: 201, description: 'New formula configuration created' })
async createNewFormulaConfiguration(@Body() dto: CreateNewFormulaDto) {
return this.service.createNewFormulaConfiguration(dto);
}
@Post('complete-setup')
@ApiOperation({
summary: 'Complete system setup (migrate + create + activate)',
description: 'Performs full system migration and setup in one operation'
})
@ApiResponse({ status: 200, description: 'System setup completed successfully' })
async completeSetup(@Body() body: { activateNewFormula?: boolean; enableFeature?: boolean }) {
// Step 1: Migrate legacy system
const migrationResult = await this.service.migrateLegacySystem({ dryRun: false });
// Step 2: Create new formula configuration
const newConfig = await this.service.createNewFormulaConfiguration({
name: 'Default System Configuration',
description: 'System-generated configuration with optimal defaults',
activateImmediately: body.activateNewFormula !== false
});
// Step 3: Enable feature flag if requested
if (body.enableFeature) {
await this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: true,
config: { rollout_percentage: 100 }
});
}
return {
migration: migrationResult,
newConfiguration: newConfig,
featureEnabled: body.enableFeature || false,
message: 'System setup completed successfully'
};
}
@Get('status')
@ApiOperation({ summary: 'Get migration and setup status' })
@ApiResponse({ status: 200, description: 'Current system status' })
async getStatus() {
const featureStatus = await this.service.getFeatureStatus('USE_CONFIGURABLE_FARES');
const configurations = await this.service.getAllConfigurations();
const activeConfig = (configurations as any[]).find(config => config.is_active);
return {
configurableFaresEnabled: featureStatus.enabled,
rolloutPercentage: featureStatus.config?.rollout_percentage || 0,
totalConfigurations: (configurations as any[]).length,
activeConfiguration: activeConfig?.id || null,
activeConfigurationName: activeConfig?.name || null,
systemReady: featureStatus.enabled && !!activeConfig
};
}
}
@ApiTags('Configurable Fares')
@Controller('admin/fare-configurations/system')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class FareSystemController {
constructor(private service: ConfigurableFareService) {}
@Get('feature-status')
@ApiOperation({ summary: 'Check configurable fares feature status' })
@ApiQuery({ name: 'feature', required: false, description: 'Feature name (defaults to USE_CONFIGURABLE_FARES)' })
@ApiResponse({ status: 200, description: 'Feature status retrieved' })
async getFeatureStatus(@Query('feature') featureName = 'USE_CONFIGURABLE_FARES') {
return this.service.getFeatureStatus(featureName);
}
@Post('toggle-feature')
@ApiOperation({ summary: 'Enable or disable configurable fares system' })
@ApiResponse({ status: 200, description: 'Feature toggled successfully' })
async toggleFeature(@Body() dto: ToggleFeatureDto) {
return this.service.toggleFeature(dto);
}
@Post('enable-configurable-fares')
@ApiOperation({
summary: 'Enable configurable fares with rollout percentage',
description: 'Quick endpoint to enable the configurable fares feature'
})
@ApiResponse({ status: 200, description: 'Configurable fares enabled successfully' })
async enableConfigurableFares(@Body() body: { rolloutPercentage?: number }) {
return this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: true,
config: { rollout_percentage: body.rolloutPercentage || 100 }
});
}
@Post('disable-configurable-fares')
@ApiOperation({
summary: 'Disable configurable fares (fallback to legacy system)',
description: 'Disables the configurable fares feature and falls back to legacy fare calculation'
})
@ApiResponse({ status: 200, description: 'Configurable fares disabled successfully' })
async disableConfigurableFares() {
return this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: false,
config: { rollout_percentage: 0 }
});
}
}

View File

@@ -0,0 +1,362 @@
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, ValidateNested, IsDateString, IsEnum, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NationalityType {
LOCAL = 'LOCAL',
INTERNATIONAL = 'INTERNATIONAL'
}
export enum CoachType {
REGULAR_SEAT = 'REGULAR_SEAT',
ECONOMY_BED = 'ECONOMY_BED',
VIP_BED = 'VIP_BED'
}
export enum BedPosition {
UPPER = 'UPPER',
MIDDLE = 'MIDDLE',
LOWER = 'LOWER'
}
export enum ComponentType {
INSURANCE = 'INSURANCE',
PREMIUM = 'PREMIUM',
SERVICE_CHARGE = 'SERVICE_CHARGE',
TAX = 'TAX',
DEMAND = 'DEMAND'
}
export enum CalculationMethod {
MULTIPLIER = 'MULTIPLIER',
PERCENTAGE = 'PERCENTAGE',
FIXED_AMOUNT = 'FIXED_AMOUNT'
}
export enum AppliesTo {
BASE_FARE = 'BASE_FARE',
SUBTOTAL = 'SUBTOTAL',
TOTAL = 'TOTAL'
}
export enum PricingType {
FREE = 'FREE',
FULL_FARE = 'FULL_FARE',
DISCOUNTED = 'DISCOUNTED'
}
export class FareRateRuleDto {
@ApiProperty({ enum: NationalityType })
@IsEnum(NationalityType)
nationalityType: NationalityType;
@ApiProperty({ enum: CoachType })
@IsEnum(CoachType)
coachType: CoachType;
@ApiPropertyOptional({ enum: BedPosition })
@IsOptional()
@IsEnum(BedPosition)
bedPosition?: BedPosition;
@ApiProperty({ example: 3000, description: 'Rate per km in minor units (e.g., 30.00 ETB = 3000)' })
@IsInt()
@Min(0)
ratePerKmMinor: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class FareComponentDto {
@ApiProperty({ enum: ComponentType })
@IsEnum(ComponentType)
componentType: ComponentType;
@ApiProperty({ example: 'Travel Insurance' })
@IsString()
componentName: string;
@ApiProperty({ enum: CalculationMethod })
@IsEnum(CalculationMethod)
calculationMethod: CalculationMethod;
@ApiPropertyOptional({ example: 500, description: 'Fixed amount in minor units' })
@IsOptional()
@IsInt()
@Min(0)
valueMinor?: number;
@ApiPropertyOptional({ example: 0.02, description: 'Percentage value (e.g., 0.02 for 2%)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
percentageValue?: number;
@ApiProperty({ enum: AppliesTo, default: AppliesTo.SUBTOTAL })
@IsEnum(AppliesTo)
appliesTo: AppliesTo;
@ApiProperty({ example: 1, description: 'Order of application' })
@IsInt()
@Min(1)
applyOrder: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class AgePricingRuleDto {
@ApiProperty({ example: 'Adult Passengers' })
@IsString()
ruleName: string;
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
minAge: number;
@ApiPropertyOptional({ example: 120 })
@IsOptional()
@IsInt()
@Min(0)
maxAge?: number;
@ApiProperty({ enum: PricingType })
@IsEnum(PricingType)
pricingType: PricingType;
@ApiPropertyOptional({ example: 0.5, description: 'Discount percentage for DISCOUNTED type' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
discountPercentage?: number;
@ApiPropertyOptional({ example: 1, description: 'Max free passengers for FREE type' })
@IsOptional()
@IsInt()
@Min(0)
maxFreePassengers?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
appliesToComponents?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateFareConfigurationDto {
@ApiProperty({ example: 'Summer 2024 Rates' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'Updated rates for summer season' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: '2024-06-01T00:00:00.000Z' })
@IsDateString()
effectiveDate: string;
@ApiPropertyOptional({ example: '2024-08-31T23:59:59.000Z' })
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiProperty({ type: [FareRateRuleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareRateRuleDto)
rateRules: FareRateRuleDto[];
@ApiProperty({ type: [FareComponentDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareComponentDto)
components: FareComponentDto[];
@ApiProperty({ type: [AgePricingRuleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AgePricingRuleDto)
ageRules: AgePricingRuleDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
export class UpdateFareConfigurationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
effectiveDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiPropertyOptional({ type: [FareRateRuleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareRateRuleDto)
rateRules?: FareRateRuleDto[];
@ApiPropertyOptional({ type: [FareComponentDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareComponentDto)
components?: FareComponentDto[];
@ApiPropertyOptional({ type: [AgePricingRuleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AgePricingRuleDto)
ageRules?: AgePricingRuleDto[];
}
export class FareTestScenarioDto {
@ApiProperty({ example: 100 })
@IsInt()
@Min(1)
distanceKm: number;
@ApiProperty({ example: 'Ethiopian' })
@IsString()
nationality: string;
@ApiProperty({ enum: CoachType })
@IsEnum(CoachType)
coachType: CoachType;
@ApiPropertyOptional({ enum: BedPosition })
@IsOptional()
@IsEnum(BedPosition)
bedPosition?: BedPosition;
@ApiProperty({ example: 2, default: 1 })
@IsInt()
@Min(1)
adultCount: number;
@ApiPropertyOptional({ example: 1, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
childCount?: number;
@ApiPropertyOptional({ example: 'SUMMER20' })
@IsOptional()
@IsString()
promoCode?: string;
@ApiPropertyOptional({ example: 500 })
@IsOptional()
@IsInt()
@Min(0)
loyaltyPoints?: number;
}
export class FareCalculationResultDto {
@ApiProperty()
baseFareMinor: number;
@ApiProperty()
componentsTotal: number;
@ApiProperty()
totalBeforeDiscounts: number;
@ApiProperty()
discountsTotal: number;
@ApiProperty()
finalTotalMinor: number;
@ApiProperty()
breakdown: Array<{
step: string;
description: string;
amount: number;
runningTotal: number;
}>;
@ApiProperty()
currency: string;
@ApiProperty()
calculationTimestamp: Date;
}
export class MigrateLegacyDto {
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
dryRun?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
migrateScheduleFares?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
migrateSegmentFares?: boolean;
}
export class CreateNewFormulaDto {
@ApiProperty({ example: 'Default Formula Configuration' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'System-generated default configuration' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
activateImmediately?: boolean;
}
export class ToggleFeatureDto {
@ApiProperty({ example: 'USE_CONFIGURABLE_FARES' })
@IsString()
featureName: string;
@ApiProperty()
@IsBoolean()
enabled: boolean;
@ApiPropertyOptional({ example: { rollout_percentage: 50 } })
@IsOptional()
config?: Record<string, any>;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigurableFareService } from './configurable-fare.service';
import { ConfigurableFareController, FareMigrationController, FareSystemController } from './configurable-fare.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ConfigurableFareController, FareMigrationController, FareSystemController],
providers: [ConfigurableFareService],
exports: [ConfigurableFareService],
})
export class ConfigurableFareModule {}

View File

@@ -0,0 +1,623 @@
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import {
CreateFareConfigurationDto,
UpdateFareConfigurationDto,
FareTestScenarioDto,
FareCalculationResultDto,
MigrateLegacyDto,
CreateNewFormulaDto,
ToggleFeatureDto,
NationalityType,
CoachType,
BedPosition,
ComponentType,
CalculationMethod,
AppliesTo,
PricingType
} from './configurable-fare.dto';
@Injectable()
export class ConfigurableFareService {
constructor(private prisma: PrismaService) {}
// Helper method to map nationality to type
private mapNationalityToType(nationality: string): NationalityType {
const upperNationality = nationality.toUpperCase();
if (upperNationality === 'ETHIOPIAN' || upperNationality === 'DJIBOUTIAN') {
return NationalityType.LOCAL;
}
return NationalityType.INTERNATIONAL;
}
// Generate unique IDs
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Configuration Management
async getAllConfigurations() {
return this.prisma.$queryRaw`
SELECT
fc.*,
COUNT(DISTINCT frr.id) as rate_rules_count,
COUNT(DISTINCT fcmp.id) as components_count,
COUNT(DISTINCT apr.id) as age_rules_count
FROM fare_configurations fc
LEFT JOIN fare_rate_rules frr ON fc.id = frr.fare_config_id AND frr.is_active = true
LEFT JOIN fare_components fcmp ON fc.id = fcmp.fare_config_id AND fcmp.is_active = true
LEFT JOIN age_pricing_rules apr ON fc.id = apr.fare_config_id AND apr.is_active = true
GROUP BY fc.id, fc.name, fc.description, fc.effective_date, fc.expiry_date,
fc.is_active, fc.is_default, fc.created_by, fc.approved_by,
fc.approved_at, fc.created_at, fc.updated_at
ORDER BY fc.created_at DESC
`;
}
async getConfigurationById(id: string) {
const config = await this.prisma.$queryRaw`
SELECT * FROM fare_configurations WHERE id = ${id}
`;
if (!Array.isArray(config) || config.length === 0) {
throw new NotFoundException(`Fare configuration ${id} not found`);
}
const rateRules = await this.prisma.$queryRaw`
SELECT * FROM fare_rate_rules WHERE fare_config_id = ${id} ORDER BY nationality_type, coach_type, bed_position
`;
const components = await this.prisma.$queryRaw`
SELECT * FROM fare_components WHERE fare_config_id = ${id} ORDER BY apply_order
`;
const ageRules = await this.prisma.$queryRaw`
SELECT * FROM age_pricing_rules WHERE fare_config_id = ${id} ORDER BY min_age
`;
return {
...config[0],
rateRules,
components,
ageRules
};
}
async createConfiguration(dto: CreateFareConfigurationDto, createdBy?: string) {
return this.prisma.$transaction(async (tx) => {
const configId = this.generateId('fc');
// Validate no overlapping active configurations
if (dto.isDefault) {
await tx.$executeRaw`
UPDATE fare_configurations SET is_default = false WHERE is_default = true
`;
}
// Create main configuration
await tx.$executeRaw`
INSERT INTO fare_configurations (
id, name, description, effective_date, expiry_date,
is_active, is_default, created_by, created_at, updated_at
) VALUES (
${configId}, ${dto.name}, ${dto.description}, ${dto.effectiveDate},
${dto.expiryDate}, false, ${dto.isDefault || false}, ${createdBy},
NOW(), NOW()
)
`;
// Create rate rules
for (const rule of dto.rateRules) {
const ruleId = this.generateId('frr');
await tx.$executeRaw`
INSERT INTO fare_rate_rules (
id, fare_config_id, nationality_type, coach_type, bed_position,
rate_per_km_minor, is_active, created_at, updated_at
) VALUES (
${ruleId}, ${configId}, ${rule.nationalityType}, ${rule.coachType},
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
NOW(), NOW()
)
`;
}
// Create components
for (const component of dto.components) {
const componentId = this.generateId('fcmp');
await tx.$executeRaw`
INSERT INTO fare_components (
id, fare_config_id, component_type, component_name, calculation_method,
value_minor, percentage_value, applies_to, apply_order, is_active,
created_at, updated_at
) VALUES (
${componentId}, ${configId}, ${component.componentType}, ${component.componentName},
${component.calculationMethod}, ${component.valueMinor || null},
${component.percentageValue || null}, ${component.appliesTo},
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
)
`;
}
// Create age rules
for (const ageRule of dto.ageRules) {
const ageRuleId = this.generateId('apr');
await tx.$executeRaw`
INSERT INTO age_pricing_rules (
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
discount_percentage, max_free_passengers, applies_to_components,
is_active, created_at, updated_at
) VALUES (
${ageRuleId}, ${configId}, ${ageRule.ruleName}, ${ageRule.minAge},
${ageRule.maxAge || null}, ${ageRule.pricingType},
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
NOW(), NOW()
)
`;
}
// Log audit entry
await this.createAuditEntry(tx, configId, 'CREATED', createdBy, { action: 'Configuration created' });
return this.getConfigurationById(configId);
});
}
async updateConfiguration(id: string, dto: UpdateFareConfigurationDto, updatedBy?: string) {
await this.getConfigurationById(id); // Validate exists
return this.prisma.$transaction(async (tx) => {
// Update main configuration
if (dto.name || dto.description !== undefined || dto.effectiveDate || dto.expiryDate !== undefined) {
await tx.$executeRaw`
UPDATE fare_configurations
SET
name = COALESCE(${dto.name}, name),
description = COALESCE(${dto.description}, description),
effective_date = COALESCE(${dto.effectiveDate}, effective_date),
expiry_date = COALESCE(${dto.expiryDate}, expiry_date),
updated_at = NOW()
WHERE id = ${id}
`;
}
// Update rate rules if provided
if (dto.rateRules) {
await tx.$executeRaw`DELETE FROM fare_rate_rules WHERE fare_config_id = ${id}`;
for (const rule of dto.rateRules) {
const ruleId = this.generateId('frr');
await tx.$executeRaw`
INSERT INTO fare_rate_rules (
id, fare_config_id, nationality_type, coach_type, bed_position,
rate_per_km_minor, is_active, created_at, updated_at
) VALUES (
${ruleId}, ${id}, ${rule.nationalityType}, ${rule.coachType},
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
NOW(), NOW()
)
`;
}
}
// Update components if provided
if (dto.components) {
await tx.$executeRaw`DELETE FROM fare_components WHERE fare_config_id = ${id}`;
for (const component of dto.components) {
const componentId = this.generateId('fcmp');
await tx.$executeRaw`
INSERT INTO fare_components (
id, fare_config_id, component_type, component_name, calculation_method,
value_minor, percentage_value, applies_to, apply_order, is_active,
created_at, updated_at
) VALUES (
${componentId}, ${id}, ${component.componentType}, ${component.componentName},
${component.calculationMethod}, ${component.valueMinor || null},
${component.percentageValue || null}, ${component.appliesTo},
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
)
`;
}
}
// Update age rules if provided
if (dto.ageRules) {
await tx.$executeRaw`DELETE FROM age_pricing_rules WHERE fare_config_id = ${id}`;
for (const ageRule of dto.ageRules) {
const ageRuleId = this.generateId('apr');
await tx.$executeRaw`
INSERT INTO age_pricing_rules (
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
discount_percentage, max_free_passengers, applies_to_components,
is_active, created_at, updated_at
) VALUES (
${ageRuleId}, ${id}, ${ageRule.ruleName}, ${ageRule.minAge},
${ageRule.maxAge || null}, ${ageRule.pricingType},
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
NOW(), NOW()
)
`;
}
}
await this.createAuditEntry(tx, id, 'UPDATED', updatedBy, { changes: dto });
return this.getConfigurationById(id);
});
}
async activateConfiguration(id: string, activatedBy?: string) {
return this.prisma.$transaction(async (tx) => {
// Deactivate all other configurations
await tx.$executeRaw`UPDATE fare_configurations SET is_active = false`;
// Activate this one
await tx.$executeRaw`
UPDATE fare_configurations
SET is_active = true, approved_by = ${activatedBy}, approved_at = NOW()
WHERE id = ${id}
`;
await this.createAuditEntry(tx, id, 'ACTIVATED', activatedBy, {});
return { success: true, message: `Configuration ${id} activated successfully` };
});
}
async deleteConfiguration(id: string, deletedBy?: string) {
const config = await this.getConfigurationById(id);
if ((config as any).is_active) {
throw new ConflictException('Cannot delete active configuration. Deactivate first.');
}
await this.prisma.$executeRaw`DELETE FROM fare_configurations WHERE id = ${id}`;
return { success: true, message: `Configuration ${id} deleted successfully` };
}
// Fare Calculation
async testConfiguration(id: string, scenario: FareTestScenarioDto): Promise<FareCalculationResultDto> {
const config = await this.getConfigurationById(id);
// Find matching rate rule
const nationalityType = this.mapNationalityToType(scenario.nationality);
const rateRule = (config.rateRules as any[]).find((rule: any) =>
rule.nationality_type === nationalityType &&
rule.coach_type === scenario.coachType &&
(scenario.bedPosition ? rule.bed_position === scenario.bedPosition : !rule.bed_position)
);
if (!rateRule) {
throw new BadRequestException(`No rate rule found for ${nationalityType}/${scenario.coachType}${scenario.bedPosition ? `/${scenario.bedPosition}` : ''}`);
}
// Calculate base fare
const baseFareMinor = scenario.distanceKm * rateRule.rate_per_km_minor;
const breakdown = [
{
step: '1',
description: `Base fare: ${scenario.distanceKm}km × ${rateRule.rate_per_km_minor} minor units/km`,
amount: baseFareMinor,
runningTotal: baseFareMinor
}
];
let runningTotal = baseFareMinor;
// Apply age-based pricing
const { adultCount = 1, childCount = 0 } = scenario;
let totalPassengerFare = 0;
// Process adults
totalPassengerFare += adultCount * baseFareMinor;
breakdown.push({
step: '2a',
description: `Adult passengers: ${adultCount} × ${baseFareMinor}`,
amount: adultCount * baseFareMinor,
runningTotal: adultCount * baseFareMinor
});
// Process children with age rules
if (childCount > 0) {
const childRule = (config.ageRules as any[]).find((rule: any) =>
rule.pricing_type === PricingType.FREE && rule.max_free_passengers > 0
);
if (childRule) {
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
const paidChildren = Math.max(0, childCount - freeChildren);
if (freeChildren > 0) {
breakdown.push({
step: '2b',
description: `Free children: ${freeChildren} × 0 (first ${childRule.max_free_passengers} free)`,
amount: 0,
runningTotal: totalPassengerFare
});
}
if (paidChildren > 0) {
const paidChildrenFare = paidChildren * baseFareMinor;
totalPassengerFare += paidChildrenFare;
breakdown.push({
step: '2c',
description: `Paid children: ${paidChildren} × ${baseFareMinor}`,
amount: paidChildrenFare,
runningTotal: totalPassengerFare
});
}
} else {
// All children pay
const childrenFare = childCount * baseFareMinor;
totalPassengerFare += childrenFare;
breakdown.push({
step: '2b',
description: `Child passengers: ${childCount} × ${baseFareMinor}`,
amount: childrenFare,
runningTotal: totalPassengerFare
});
}
}
runningTotal = totalPassengerFare;
// Apply components in order
let componentsTotal = 0;
const components = (config.components as any[])
.filter((c: any) => c.is_active)
.sort((a: any, b: any) => a.apply_order - b.apply_order);
for (const component of components) {
let componentAmount = 0;
let baseAmount = runningTotal;
if (component.applies_to === 'BASE_FARE') {
baseAmount = baseFareMinor;
} else if (component.applies_to === 'SUBTOTAL') {
baseAmount = runningTotal;
}
switch (component.calculation_method) {
case 'PERCENTAGE':
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
break;
case 'MULTIPLIER':
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
break;
case 'FIXED_AMOUNT':
componentAmount = component.value_minor || 0;
break;
}
componentsTotal += componentAmount;
runningTotal += componentAmount;
breakdown.push({
step: `3${String.fromCharCode(97 + component.apply_order - 1)}`,
description: `${component.component_name}: ${component.calculation_method} on ${component.applies_to}`,
amount: componentAmount,
runningTotal
});
}
return {
baseFareMinor,
componentsTotal,
totalBeforeDiscounts: runningTotal,
discountsTotal: 0, // TODO: Implement promo code discounts
finalTotalMinor: runningTotal,
breakdown,
currency: 'ETB',
calculationTimestamp: new Date()
};
}
// Migration Methods
async migrateLegacySystem(dto: MigrateLegacyDto) {
const results = {
scheduleFareRules: 0,
segmentFareRules: 0,
configurationsCreated: 0,
dryRun: dto.dryRun || false
};
if (dto.dryRun) {
// Count what would be migrated
const scheduleFares = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM "FareRule" WHERE migrated_to_config_id IS NULL
`;
const segmentFares = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM "SegmentFareRule" WHERE migrated_to_config_id IS NULL
`;
results.scheduleFareRules = Number((scheduleFares as any[])[0]?.count || 0);
results.segmentFareRules = Number((segmentFares as any[])[0]?.count || 0);
return results;
}
// Create a migration configuration based on existing rules
const migrationConfig: CreateFareConfigurationDto = {
name: 'Legacy Migration Configuration',
description: 'Automatically migrated from existing fare rules',
effectiveDate: new Date().toISOString(),
rateRules: [
// Default rates based on current system
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
],
components: [
{
componentType: ComponentType.INSURANCE,
componentName: 'Travel Insurance',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.02,
appliesTo: AppliesTo.BASE_FARE,
applyOrder: 1
},
{
componentType: ComponentType.TAX,
componentName: 'Government Tax',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.05,
appliesTo: AppliesTo.SUBTOTAL,
applyOrder: 2
}
],
ageRules: [
{
ruleName: 'Adult Passengers',
minAge: 5,
pricingType: PricingType.FULL_FARE
},
{
ruleName: 'Child Passengers (First Free)',
minAge: 0,
maxAge: 4,
pricingType: PricingType.FREE,
maxFreePassengers: 1
}
]
};
const newConfig = await this.createConfiguration(migrationConfig, 'system-migration');
results.configurationsCreated = 1;
return results;
}
async createNewFormulaConfiguration(dto: CreateNewFormulaDto) {
const config = await this.createConfiguration({
name: dto.name,
description: dto.description || 'System-generated default configuration',
effectiveDate: new Date().toISOString(),
rateRules: [
// Default rates for all combinations
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 8000 },
// International rates (2x local)
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 16000 },
],
components: [
{
componentType: ComponentType.INSURANCE,
componentName: 'Travel Insurance',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.02,
appliesTo: AppliesTo.BASE_FARE,
applyOrder: 1
},
{
componentType: ComponentType.SERVICE_CHARGE,
componentName: 'Service Charge',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.03,
appliesTo: AppliesTo.SUBTOTAL,
applyOrder: 2
},
{
componentType: ComponentType.TAX,
componentName: 'Government Tax',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.05,
appliesTo: AppliesTo.TOTAL,
applyOrder: 3
}
],
ageRules: [
{
ruleName: 'Adult Passengers',
minAge: 5,
pricingType: PricingType.FULL_FARE
},
{
ruleName: 'Child Passengers (First Free)',
minAge: 0,
maxAge: 4,
pricingType: PricingType.FREE,
maxFreePassengers: 1
}
],
isDefault: true
}, 'system');
if (dto.activateImmediately) {
await this.activateConfiguration((config as any).id, 'system');
}
return config;
}
// Feature Management
async toggleFeature(dto: ToggleFeatureDto) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
INSERT INTO system_features (id, feature_name, is_enabled, config, created_at, updated_at)
VALUES (${this.generateId('sf')}, ${dto.featureName}, ${dto.enabled},
${JSON.stringify(dto.config || {})}, NOW(), NOW())
ON CONFLICT (feature_name) DO UPDATE SET
is_enabled = ${dto.enabled},
config = ${JSON.stringify(dto.config || {})},
updated_at = NOW()
`;
return { success: true, message: `Feature ${dto.featureName} ${dto.enabled ? 'enabled' : 'disabled'}` };
});
}
async getFeatureStatus(featureName: string) {
const result = await this.prisma.$queryRaw`
SELECT * FROM system_features WHERE feature_name = ${featureName}
`;
if (!Array.isArray(result) || result.length === 0) {
return { enabled: false, config: {} };
}
const feature = result[0] as any;
return {
enabled: feature.is_enabled,
config: feature.config || {}
};
}
async getAuditTrail(configId: string) {
return this.prisma.$queryRaw`
SELECT * FROM fare_configuration_audit
WHERE fare_config_id = ${configId}
ORDER BY timestamp DESC
`;
}
// Private helper methods
private async createAuditEntry(tx: any, configId: string, action: string, changedBy?: string, changes?: any) {
const auditId = this.generateId('fca');
await tx.$executeRaw`
INSERT INTO fare_configuration_audit (
id, fare_config_id, action, changed_by, changes, timestamp
) VALUES (
${auditId}, ${configId}, ${action}, ${changedBy || 'system'},
${JSON.stringify(changes || {})}, NOW()
)
`;
}
}

View File

@@ -19,7 +19,7 @@ export class DashboardService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, liveStatus: true } },
seats: { include: { seat: { include: { coach: true } } }, take: 1 },
ticket: true,
tickets: { take: 1 },
},
orderBy: { createdAt: 'asc' },
}),
@@ -42,16 +42,16 @@ export class DashboardService {
const name = iamRows[0]?.name;
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
}
const seat = upcomingBooking?.seats[0];
const seat = (upcomingBooking as any)?.seats?.[0];
return {
user: { firstName, greetingKey },
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: upcomingBooking.schedule.departureAt,
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
ticketId: (upcomingBooking as any).tickets?.[0]?.id, bookingRef: upcomingBooking.bookingRef,
from: (upcomingBooking as any).schedule.originStation.name, to: (upcomingBooking as any).schedule.destinationStation.name,
trainName: (upcomingBooking as any).schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: (upcomingBooking as any).schedule.departureAt,
punctualityLabel: ((upcomingBooking as any).schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
activePromotionsCount: promos,

View File

@@ -91,6 +91,12 @@ export class ExcessBaggageAgentController {
deleteAllowance(@Param('id') id: string) {
return this.service.deleteAllowance(id);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
deleteCharge(@Param('id') id: string) {
return this.service.deleteCharge(id);
}
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────

View File

@@ -252,6 +252,15 @@ export class ExcessBaggageService {
};
}
// Mark expired charges before fetching
await this.prisma.excessBaggageCharge.updateMany({
where: {
status: 'PENDING',
expiresAt: { lt: new Date() },
},
data: { status: 'EXPIRED' },
});
const [items, total] = await Promise.all([
this.prisma.excessBaggageCharge.findMany({
where,
@@ -291,4 +300,12 @@ export class ExcessBaggageService {
await this.prisma.baggageAllowance.delete({ where: { id } });
return { deleted: true };
}
async deleteCharge(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
await this.prisma.excessBaggageCharge.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -28,14 +28,16 @@ export class FareEngineService {
if (originStop.sequence >= destStop.sequence)
throw new BadRequestException('Origin must come before destination in the route sequence');
const legStops = route.stops.filter(
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
);
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
// Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
throw new BadRequestException('Invalid distance calculation - check route stop distances');
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
const now = new Date();
const [originStation, destStation] = await Promise.all([
@@ -64,23 +66,15 @@ export class FareEngineService {
let baseFarePerPassengerMinor: number;
let ratePerKmMinor: number;
let totalDistanceKm: number;
let fareSource: string;
if (fareRule) {
// Flat fare from FareRule — distance is informational only
baseFarePerPassengerMinor = fareRule.baseFareMinor;
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
} else {
// Distance × rate fallback
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
if (missingDistance.length > 0)
throw new BadRequestException(
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
);
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
ratePerKmMinor = seatClass.baseFareMinor;
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
fareSource = 'DISTANCE_RATE';

View File

@@ -174,13 +174,13 @@ export class FleetController {
example: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
code: 'HSC',
name: 'Hard Seat Coach'
},
arrangement: '2+2',
capacity: 60,
@@ -215,13 +215,13 @@ export class FleetController {
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
code: 'HSC',
name: 'Hard Seat Coach'
},
arrangement: '2+2',
capacity: 60,
@@ -257,7 +257,7 @@ export class FleetController {
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',
@@ -283,7 +283,7 @@ export class FleetController {
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
number: 'HSC-0001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',

View File

@@ -11,7 +11,7 @@ export class CreateTrainDto {
}
export class CreateCoachDto {
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'HSC-0001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
@@ -53,22 +53,22 @@ export class ListCoachesDto {
// Legacy DTO types for backward compatibility
export class CreateCoachTypeDto {
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
@ApiProperty({ example: 'HSC' }) @IsString() code: string;
@ApiProperty({ example: 'Hard Seat Coach' }) @IsString() name: string;
@IsOptional() @IsString() type?: string;
}
export class UpdateCoachTypeDto {
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
@ApiPropertyOptional({ example: 'HSC' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Hard Seat Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'Regular Seat' }) @IsOptional() @IsString() type?: string;
}
export class CreateClassDto {
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
@ApiProperty({ example: 500 }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
@@ -78,7 +78,7 @@ export class UpdateClassDto {
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
function parseArrangement(arrangement: string): number[] {
@@ -41,7 +42,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
ECONOMY_BED: 6,
};
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
// Name-based fallback: checks if 'vip' is present for any bed coach type
function detectBedCategory(coachTypeName: string): BedCategory {
const name = coachTypeName.toLowerCase();
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
@@ -201,17 +202,25 @@ export class FleetService {
});
if (!coachType) throw new NotFoundException('Coach type not found');
// Check for related records
const constraints = [];
if (coachType.coaches.length > 0) {
throw new BadRequestException(
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
);
constraints.push({
entityName: 'coach',
count: coachType.coaches.length,
action: 'reassign' as const
});
}
if (coachType.seatClasses.length > 0) {
throw new BadRequestException(
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
);
constraints.push({
entityName: 'seat class',
count: coachType.seatClasses.length,
action: 'reassign' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
}
return this.prisma.coachType.delete({ where: { id } });
@@ -273,17 +282,19 @@ export class FleetService {
});
if (!seatClass) throw new NotFoundException('Seat class not found');
// Check for related records
const relatedRecords = [
...seatClass.fareRules,
...seatClass.routeFareRules,
...seatClass.segmentFares,
];
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
const constraints = [];
if (relatedRecords.length > 0) {
throw new BadRequestException(
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
);
if (totalFareRules > 0) {
constraints.push({
entityName: 'fare rule',
count: totalFareRules,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
}
return this.prisma.seatClass.delete({ where: { id } });
@@ -344,11 +355,20 @@ export class FleetService {
include: { schedules: true },
});
if (!train) throw new NotFoundException('Train not found');
const constraints = [];
if (train.schedules.length > 0) {
throw new BadRequestException(
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
);
constraints.push({
entityName: 'schedule',
count: train.schedules.length,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
}
return this.prisma.train.delete({ where: { id } });
}
@@ -460,45 +480,54 @@ export class FleetService {
include: {
bookingSeats: true,
blocks: true,
ticketSeats: true,
tickets: true,
},
},
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Check for active assignments
if (coach.assignments.length > 0) {
throw new BadRequestException(
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
);
const constraints = [];
if ((coach as any).assignments.length > 0) {
constraints.push({
entityName: 'schedule assignment',
count: (coach as any).assignments.length,
action: 'reassign' as const
});
}
// Check for booked seats
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
if (bookedSeats.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
);
constraints.push({
entityName: 'booked seat',
count: bookedSeats.length,
action: 'complete' as const
});
}
// Check for blocked seats
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
);
constraints.push({
entityName: 'blocked seat',
count: blockedSeats.length,
action: 'delete' as const
});
}
// Check for tickets
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
if (seatsWithTickets.length > 0) {
throw new BadRequestException(
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
);
constraints.push({
entityName: 'seat with issued ticket',
count: seatsWithTickets.length,
action: 'complete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach', coach.number, constraints);
}
// Delete related seats first (now safe to do)
await this.prisma.seat.deleteMany({ where: { coachId: id } });
return this.prisma.coach.delete({ where: { id } });

View File

@@ -280,7 +280,7 @@ export class NotificationsService {
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
});
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId } });
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const amount = this.formatAmount(booking ?? payload.booking);

View File

@@ -21,22 +21,152 @@ export class PassengersController {
@Get()
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'List all passengers with filters (Admin/Agent)',
description: 'Returns paginated list of passengers with search filters'
summary: 'List all travelers with filters (Admin/Agent)',
description: `**Returns paginated list of all travelers in the system**
---
### Data Source
- Fetches from **TravelerProfile** table (created during booking)
- Shows ALL passengers from ALL bookings (including guest bookings)
- Each row represents a unique traveler, not a user account
---
### Features
- Search by name, email, phone
- Filter by gender
- Date range filtering (createdAt)
- Pagination support (page, pageSize)
- Includes loyalty and wallet info if linked to user account
- Shows booking count per traveler
---
### Response Fields
- **id**: TravelerProfile ID
- **fullName**: Passenger name
- **email/phone**: Contact info (from linked user or booking)
- **gender**: Male/Female/Other (from Verifayda or manual entry)
- **dateOfBirth**: Birth date in YYYY-MM-DD format
- **nationality**: Passenger nationality
- **faydaVerified**: Whether verified via Verifayda
- **loyaltyTier/loyaltyPoints**: If linked to user account
- **totalBookings**: Number of bookings
- **createdAt**: When traveler was first added to system`
})
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
@ApiQuery({ name: 'gender', required: false, description: 'Filter by gender (Male, Female, Other)' })
@ApiQuery({ name: 'dateFrom', required: false, description: 'Filter by creation date from (YYYY-MM-DD)' })
@ApiQuery({ name: 'dateTo', required: false, description: 'Filter by creation date to (YYYY-MM-DD)' })
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page (default: 20)' })
@ApiResponse({
status: 200,
description: 'Travelers retrieved successfully',
schema: {
example: {
items: [
{
id: 'uuid-123',
fullName: 'Abebe Kebede',
email: 'abebe@example.com',
phone: '+251911234567',
gender: 'Male',
dateOfBirth: '1985-03-15',
nationality: 'Ethiopian',
faydaVerified: true,
loyaltyTier: 'SILVER',
loyaltyPoints: 1500,
totalBookings: 5,
createdAt: '2024-01-10T12:00:00.000Z'
}
],
meta: {
page: 1,
pageSize: 20,
total: 150,
totalPages: 8
}
}
}
})
@ApiResponse({
status: 200,
description: 'Travelers retrieved successfully',
schema: {
example: {
items: [
{
id: 'uuid-123',
fullName: 'Abebe Kebede',
email: 'abebe@example.com',
phone: '+251911234567',
gender: 'Male',
dateOfBirth: '1985-03-15',
nationality: 'Ethiopian',
nationalityCode: 'ET',
faydaVerified: true,
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
passportNumber: null,
passportCountry: null,
passportExpiryDate: null,
idDocumentType: 'NATIONAL_ID',
verified: true,
lastLoginAt: '2024-01-20T08:15:00.000Z',
role: 'PASSENGER',
loyalty: {
tier: 'SILVER',
pointsBalance: 1500,
lifetimePoints: 3000
},
wallet: {
balanceMinor: 50000,
currency: 'ETB'
},
loyaltyTier: 'SILVER',
loyaltyPoints: 1500,
totalBookings: 5,
createdAt: '2024-01-10T12:00:00.000Z'
},
{
id: 'uuid-456',
fullName: 'Sara Ketsela',
email: null,
phone: null,
gender: 'Female',
dateOfBirth: '1990-08-22',
nationality: 'Ethiopian',
nationalityCode: null,
faydaVerified: false,
faydaVerifiedAt: null,
passportNumber: null,
passportCountry: null,
passportExpiryDate: null,
idDocumentType: null,
verified: false,
lastLoginAt: null,
role: null,
loyalty: null,
wallet: null,
loyaltyTier: 'BRONZE',
loyaltyPoints: 0,
totalBookings: 1,
createdAt: '2024-01-18T14:30:00.000Z'
}
],
meta: {
page: 1,
pageSize: 20,
total: 150,
totalPages: 8
}
}
}
})
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'verified', required: false })
@ApiQuery({ name: 'gender', required: false })
@ApiQuery({ name: 'nationality', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
findAll(
@Query('search') search?: string,
@Query('verified') verified?: string,
@Query('gender') gender?: string,
@Query('nationality') nationality?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('page') page?: string,
@@ -44,9 +174,7 @@ export class PassengersController {
) {
return this.service.findAll({
search,
verified: verified ? verified === 'true' : undefined,
gender,
nationality,
dateFrom,
dateTo,
page: page ? parseInt(page) : 1,

View File

@@ -8,6 +8,7 @@ export class CreateTravelerProfileDto {
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
@ApiPropertyOptional({ example: 'Female' }) @IsOptional() @IsString() gender?: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}

View File

@@ -4,12 +4,11 @@ import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
interface PassengerFilters {
search?: string;
verified?: boolean;
gender?: string;
nationality?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
@@ -33,31 +32,21 @@ export class PassengersService {
) {}
async findAll(filters: PassengerFilters = {}) {
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const { search, gender, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.user = {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
{ fullName: { contains: search, mode: 'insensitive' } },
],
};
}
if (verified !== undefined) {
where.user = { ...(where.user ?? {}), faydaVerified: verified };
where.OR = [
{ fullName: { contains: search, mode: 'insensitive' } },
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
];
}
if (gender) {
where.user = { ...(where.user ?? {}), gender };
}
if (nationality) {
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
where.gender = gender;
}
if (dateFrom || dateTo) {
@@ -68,34 +57,43 @@ export class PassengersService {
}
const [items, total] = await Promise.all([
this.prisma.passenger.findMany({
this.prisma.travelerProfile.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: true,
loyalty: true,
wallet: true,
_count: { select: { bookings: true } },
bookings: {
orderBy: { createdAt: 'desc' },
take: 1,
select: {
contactEmail: true,
contactPhone: true,
seats: { take: 1, orderBy: { id: 'asc' }, select: {
passengerName: true, dateOfBirth: true, passportNumber: true,
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
}},
passenger: {
include: {
user: true,
loyalty: true,
wallet: true,
_count: { select: { bookings: true } },
bookings: {
take: 1,
orderBy: { createdAt: 'desc' },
select: {
contactPhone: true,
contactEmail: true,
seats: {
take: 1,
orderBy: { id: 'asc' },
select: {
passengerName: true,
passportNumber: true,
passportCountry: true,
},
},
},
},
},
},
},
}),
this.prisma.passenger.count({ where }),
this.prisma.travelerProfile.count({ where }),
]);
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
const iamUserIds = items.map(p => p.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
@@ -104,72 +102,51 @@ export class PassengersService {
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
// Collect guest contact details for bulk SavedPassengerProfile lookup
const guestContacts = items
.filter(p => !(p as any).user && !p.iamUserId)
.map(p => (p as any).bookings?.[0])
.filter(Boolean);
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
const savedProfiles = (guestEmails.length || guestPhones.length)
? await this.prisma.savedPassengerProfile.findMany({
where: { OR: [
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
]},
orderBy: { createdAt: 'desc' },
})
: [];
// Index by email then phone for O(1) lookup
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
return {
items: items.map(passenger => {
const localUser = (passenger as any).user ?? null;
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
items: items.map(profile => {
const passenger = profile.passenger;
const localUser = (passenger as any)?.user ?? null;
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
const faydaVerified = localUser?.faydaVerified === true
|| iam?.metadata?.faydaVerified === true
|| iam?.metadata?.faydaVerified === 'true';
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
// Get additional data from bookings for guest passengers
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
const guestSeat = guestBooking?.seats?.[0] ?? null;
const savedProfile = guestBooking
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
: null;
return {
id: passenger.id,
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
dateOfBirth: localUser?.dateOfBirth
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
id: profile.id,
fullName: profile.fullName,
email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null,
phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null,
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
dateOfBirth: profile.dateOfBirth
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
: (localUser?.dateOfBirth
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
: iam?.metadata?.dateOfBirth ?? null),
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
faydaVerified,
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null,
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null,
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null,
verified: faydaVerified,
lastLoginAt: localUser?.lastLoginAt ?? null,
role: localUser?.role ?? null,
loyalty: passenger.loyalty
loyalty: passenger?.loyalty
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
: null,
wallet: (passenger as any).wallet
wallet: (passenger as any)?.wallet
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
: null,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
loyaltyTier: passenger?.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger?.loyalty?.pointsBalance || 0,
totalBookings: passenger?._count?.bookings || 0,
createdAt: profile.createdAt,
};
}),
meta: {
@@ -299,8 +276,13 @@ export class PassengersService {
createTravelerProfile(dto: CreateTravelerProfileDto) {
return this.prisma.travelerProfile.create({
data: {
...dto,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
passengerId: dto.passengerId,
fullName: dto.fullName,
relationship: dto.relationship,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null,
nationalId: dto.nationalId || null,
gender: dto.gender || null,
notes: dto.notes || null,
}
});
}
@@ -440,9 +422,21 @@ export class PassengersService {
}
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
const passenger = await this.prisma.passenger.findUnique({
where: { id },
include: {
user: true
}
});
if (!passenger) throw new NotFoundException('Passenger not found');
// Check usage before allowing deletion
const usage = await this.checkPassengerUsage(id);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
@@ -470,14 +464,15 @@ export class PassengersService {
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
]);
const usage = [];
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
if (loyaltyAccount) usage.push('Loyalty account');
if (walletAccount) usage.push('Wallet account');
const constraints = [];
if (bookingCount > 0) constraints.push({ entityName: 'booking', count: bookingCount, action: 'complete' as const });
if (loyaltyAccount) constraints.push({ entityName: 'loyalty account', count: 1, action: 'delete' as const });
if (walletAccount) constraints.push({ entityName: 'wallet account', count: 1, action: 'delete' as const });
return {
isInUse: usage.length > 0,
affectedModules: usage,
isInUse: constraints.length > 0,
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
constraints
};
}
}

View File

@@ -4,6 +4,7 @@ import {
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
Res,
@@ -123,6 +124,16 @@ export class PaymentsController {
return this.service.addPaymentMethod(dto);
}
@Patch("methods/:id")
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Update a payment method configuration (admin only)",
})
updateMethod(@Param("id") id: string, @Body() dto: Partial<AddPaymentMethodDto>) {
return this.service.updatePaymentMethod(id, dto);
}
@Get("methods")
@SetMetadata('isPublic', true)
@ApiOperation({

View File

@@ -473,6 +473,24 @@ export class PaymentsService {
});
}
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Payment method not found');
const updateData: any = {};
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
if (dto.currency !== undefined) updateData.currency = dto.currency;
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
return this.prisma.paymentMethod.update({
where: { id },
data: updateData,
});
}
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
return this.prisma.paymentMethod.findMany({
where: {

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
export class RoutesService {
@@ -93,8 +94,28 @@ export class RoutesService {
}
async deleteRoute(id: string) {
const route = await this.prisma.route.findUnique({ where: { id } });
const route = await this.prisma.route.findUnique({
where: { id },
include: {
schedules: true,
stops: true
}
});
if (!route) throw new NotFoundException('Route not found');
const constraints = [];
if (route.schedules.length > 0) {
constraints.push({
entityName: 'schedule',
count: route.schedules.length,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
}
await this.prisma.route.delete({ where: { id } });
return { deleted: true, id };
}

View File

@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
@Injectable()
export class SchedulesService {
@@ -13,7 +15,7 @@ export class SchedulesService {
) { }
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = new Date(dto.startDateTime);
const startDate = parseEthiopianTime(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
const errors: string[] = [];
const scheduleIds: string[] = [];
@@ -66,8 +68,8 @@ export class SchedulesService {
const where: any = {};
if (dto.date) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
const date = parseEthiopianTime(dto.date);
const nextDay = startOfNextDayEAT(date);
where.departureAt = { gte: date, lt: nextDay };
}
if (dto.routeId) where.routeId = dto.routeId;
@@ -93,8 +95,9 @@ export class SchedulesService {
}
async createSchedule(dto: CreateScheduleDto) {
const dep = new Date(dto.departureAt);
const arr = new Date(dto.arrivalAt);
// Parse dates in local Ethiopian time (EAT - UTC+3)
const dep = parseEthiopianTime(dto.departureAt);
const arr = parseEthiopianTime(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
const route = await this.prisma.route.findUnique({
@@ -105,10 +108,9 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
const depDate = new Date(dep);
depDate.setHours(0, 0, 0, 0);
const nextDay = new Date(depDate);
nextDay.setDate(nextDay.getDate() + 1);
// Check for existing schedule on the same day (local Ethiopian time)
const depDate = startOfDayEAT(dep);
const nextDay = startOfNextDayEAT(dep);
const existingSchedule = await this.prisma.trainSchedule.findFirst({
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
@@ -240,8 +242,9 @@ export class SchedulesService {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const dep = new Date(dto.departureAt);
const arr = new Date(dto.arrivalAt);
// Parse dates in local Ethiopian time (EAT - UTC+3)
const dep = parseEthiopianTime(dto.departureAt);
const arr = parseEthiopianTime(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
const route = await this.prisma.route.findUnique({
@@ -308,16 +311,59 @@ export class SchedulesService {
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: { _count: { select: { bookings: true } } },
include: {
_count: { select: { bookings: true } },
train: true,
originStation: true,
destinationStation: true
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
const constraints = [];
if ((schedule as any)._count.bookings > 0) {
throw new BadRequestException(
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
);
constraints.push({
entityName: 'booking',
count: (schedule as any)._count.bookings,
action: 'cancel' as const
});
}
if (constraints.length > 0) {
const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`;
throw new DeleteOperationException('Schedule', scheduleName, constraints);
}
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
// Delete travel packages that reference this schedule (required fields cannot be nulled)
// First get packages that reference this schedule
const packagesToDelete = await this.prisma.travelPackage.findMany({
where: {
OR: [
{ outboundScheduleId: id },
{ returnScheduleId: id }
]
},
select: { id: true }
});
// Delete price tiers first (they have foreign key to packages)
if (packagesToDelete.length > 0) {
const packageIds = packagesToDelete.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({
where: { packageId: { in: packageIds } }
});
// Now delete the packages
await this.prisma.travelPackage.deleteMany({
where: { id: { in: packageIds } }
});
}
return this.prisma.trainSchedule.delete({ where: { id } });
}
@@ -338,8 +384,8 @@ export class SchedulesService {
return this.prisma.tripStopTime.update({
where: { scheduleId_sequence: { scheduleId, sequence } },
data: {
plannedArrivalAt: dto.plannedArrivalAt ? new Date(dto.plannedArrivalAt) : undefined,
plannedDepartureAt: dto.plannedDepartureAt ? new Date(dto.plannedDepartureAt) : undefined,
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
plannedDepartureAt: dto.plannedDepartureAt ? parseEthiopianTime(dto.plannedDepartureAt) : undefined,
status: dto.status,
},
include: { station: true },
@@ -353,8 +399,8 @@ export class SchedulesService {
...rest,
tripId: scheduleId,
nationality,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
validFrom: parseEthiopianTime(validFrom),
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true },
});
@@ -371,8 +417,8 @@ export class SchedulesService {
...rest,
...(scheduleId !== undefined && { tripId: scheduleId }),
...(nationality !== undefined && { nationality }),
...(validFrom && { validFrom: new Date(validFrom) }),
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
...(validFrom && { validFrom: parseEthiopianTime(validFrom) }),
...(validUntil !== undefined && { validUntil: validUntil ? parseEthiopianTime(validUntil) : null }),
},
include: { seatClass: true },
});
@@ -390,8 +436,8 @@ export class SchedulesService {
return this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
validFrom: parseEthiopianTime(validFrom),
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true, route: true },
});
@@ -415,8 +461,8 @@ export class SchedulesService {
where: { id },
data: {
...rest,
validFrom: validFrom ? new Date(validFrom) : undefined,
validUntil: validUntil ? new Date(validUntil) : null,
validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined,
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true, route: true },
});
@@ -523,8 +569,8 @@ export class SchedulesService {
const updateData: any = {};
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
updateData.departureAt = dep;
updateData.arrivalAt = arr;

View File

@@ -33,7 +33,7 @@ export class SeatsController {
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Get seat map filtered by coach type",
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
})
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
@ApiQuery({
@@ -42,6 +42,23 @@ export class SeatsController {
description:
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
})
@ApiQuery({
name: "journeyDirection",
required: false,
enum: ['ONE_WAY', 'OUTBOUND', 'RETURN'],
description:
"Journey direction for round-trip bookings. Filters seat holds to show only conflicting holds. Use OUTBOUND for outbound leg, RETURN for return leg. Defaults to ONE_WAY (shows all holds).",
})
@ApiQuery({
name: "originStationId",
required: false,
description: "Origin station UUID for segment-specific seat availability",
})
@ApiQuery({
name: "destinationStationId",
required: false,
description: "Destination station UUID for segment-specific seat availability",
})
@ApiResponse({
status: 200,
description:
@@ -50,8 +67,17 @@ export class SeatsController {
getSeatMap(
@Param("scheduleId") scheduleId: string,
@Query("coachTypeId") coachTypeId?: string,
@Query("journeyDirection") journeyDirection?: string,
@Query("originStationId") originStationId?: string,
@Query("destinationStationId") destinationStationId?: string,
) {
return this.service.getSeatMap(scheduleId, coachTypeId);
return this.service.getSeatMap(
scheduleId,
coachTypeId,
journeyDirection as any,
originStationId,
destinationStationId
);
}
// ── Hold / Release ────────────────────────────────────────────────────────

View File

@@ -1,7 +1,13 @@
import { IsString, IsArray, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export enum JourneyDirection {
ONE_WAY = 'ONE_WAY',
OUTBOUND = 'OUTBOUND',
RETURN = 'RETURN'
}
export class PassengerSeatDto {
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
@IsString() passengerId: string;
@@ -20,6 +26,15 @@ export class HoldSeatsDto {
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@ApiPropertyOptional({
enum: JourneyDirection,
example: JourneyDirection.OUTBOUND,
description: 'Journey direction for round-trip bookings. ONE_WAY for single journeys, OUTBOUND/RETURN for round-trip legs. Allows same seats to be held for different directions.'
})
@IsOptional()
@IsEnum(JourneyDirection)
journeyDirection?: JourneyDirection;
@ApiProperty({
type: [PassengerSeatDto],
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',

View File

@@ -1,6 +1,6 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@@ -13,7 +13,7 @@ export class SeatsService {
private systemConfig: SystemConfigService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string) {
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
@@ -37,7 +37,13 @@ export class SeatsService {
});
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
const effectiveStatuses = await this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
journeyDirection
);
return {
coaches: assignments.map((a) => {
@@ -179,6 +185,7 @@ export class SeatsService {
seatIds: string[],
originStationId?: string,
destinationStationId?: string,
journeyDirection?: JourneyDirection,
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
@@ -211,9 +218,13 @@ export class SeatsService {
select: { seatIds: true, createdBy: true },
});
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
for (const hold of activeHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
let holdDirection = JourneyDirection.ONE_WAY;
try {
if (hold.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(hold.createdBy);
@@ -221,16 +232,26 @@ export class SeatsService {
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
}
} catch { /* ignore */ }
for (const seatId of hold.seatIds) {
if (!seatIds.includes(seatId)) continue;
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
} else {
statusMap.set(seatId, 'HELD');
}
// Check leg overlap
const legsOverlap =
reqFrom === undefined || reqTo === undefined ||
holdFrom === undefined || holdTo === undefined ||
(holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
// Check direction conflict
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
if (!directionsConflict) continue;
statusMap.set(seatId, 'HELD');
}
}
@@ -266,6 +287,36 @@ export class SeatsService {
return statusMap;
}
/**
* Check if two journey directions conflict (should not be allowed simultaneously)
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
*/
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
return false;
}
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
if (current === existing) {
return true;
}
// ONE_WAY conflicts with other ONE_WAY bookings only
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
return true;
}
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
return true;
}
// Default: no conflict
return false;
}
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
@@ -307,19 +358,16 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) =>
stopTimes.find(s => s.stationId === stationId)?.sequence;
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
@@ -328,57 +376,46 @@ export class SeatsService {
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── Check existing holds for overlap ────────────────────────────────────
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
for (const h of activeHolds) {
const rawSeatIds = h.seatIds as string[];
let holdDirection = JourneyDirection.ONE_WAY;
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
let passengerIds: string[] = [];
let legUnknown = true;
try {
if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId);
parsedHolds.push({
seatIds: rawSeatIds,
from: holdFrom ?? 0,
to: holdTo ?? Number.MAX_SAFE_INTEGER,
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
legUnknown: holdFrom === undefined || holdTo === undefined,
});
} else {
// Legacy plain-string createdBy — can't determine leg; block conservatively.
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
holdFrom = seqOf(meta.originStationId) ?? 0;
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
legUnknown = !meta.originStationId || !meta.destinationStationId;
}
} catch {
// Malformed JSON — block conservatively.
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
}
}
} catch { /* ignore */ }
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
if (!legsOverlap) continue;
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg`,
);
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
if (!directionsConflict) continue;
for (const { passengerId, seatId } of dto.passengers) {
if (rawSeatIds.includes(seatId)) {
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
}
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg`,
);
if (!legUnknown && passengerIds.includes(passengerId)) {
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
}
}
}
// ── Check confirmed JourneySegments for overlap ──────────────────────────
const bookedSegments = await tx.journeySegment.findMany({
where: {
scheduleId: dto.scheduleId,
@@ -392,25 +429,19 @@ export class SeatsService {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
const overlaps = (segFrom === undefined || segTo === undefined)
? true
: segFrom < reqTo && reqFrom < segTo;
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
if (overlaps) {
throw new ConflictException(
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
);
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
}
}
const holdMeta = {
originStationId: dto.originStationId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: currentDirection,
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
// Mark seats as HELD so the status check catches them immediately on any
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
await tx.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'HELD' },
@@ -418,10 +449,10 @@ export class SeatsService {
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
scheduleId: dto.scheduleId,
passengerId: dto.passengers[0].passengerId,
seatIds,
createdBy: JSON.stringify(holdMeta),
createdBy: JSON.stringify(holdMeta),
expiresAt,
},
});

View File

@@ -5,7 +5,6 @@ export class CreateStationDto {
@ApiProperty({ example: 'ADD' }) @IsString() code: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;

View File

@@ -68,8 +68,8 @@ export class StationsService {
async update(id: string, dto: Partial<CreateStationDto>) {
const oldStation = await this.findOne(id);
const { code, name, city, timezone, lat, lng } = dto;
const data: any = { code, name, city, timezone, lat, lng };
const { code, name, city, lat, lng } = dto;
const data: any = { code, name, city, lat, lng };
if ('countryCode' in dto) data.countryCode = (dto as any).countryCode;
if ('sequence' in dto) data.sequence = (dto as any).sequence;
if ('isOperational' in dto) data.isOperational = (dto as any).isOperational;

View File

@@ -39,6 +39,7 @@ export class TicketsController {
@ApiQuery({ name: 'arrivalDate', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'coachId', required: false })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@@ -49,6 +50,7 @@ export class TicketsController {
@Query('arrivalDate') arrivalDate?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('coachId') coachId?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
@@ -60,6 +62,7 @@ export class TicketsController {
arrivalDate,
dateFrom,
dateTo,
coachId,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
@@ -83,6 +86,31 @@ export class TicketsController {
return this.service.getByRef(ref);
}
@Post('scan-board/:qrCodeOrRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Scan QR code or booking ref and automatically board ticket',
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
})
@ApiBody({
schema: {
type: 'object',
required: ['validatorId'],
properties: {
validatorId: { type: 'string', example: 'agent-uuid' },
gateId: { type: 'string', example: 'gate-01' },
},
},
})
scanAndBoard(
@Param('qrCodeOrRef') qrCodeOrRef: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string,
) {
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
}
@Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -23,12 +23,13 @@ export class TicketsService {
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ passengerName: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
@@ -53,6 +54,12 @@ export class TicketsService {
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
// if (filters.coachId) {
// where.seat = {
// coachId: filters.coachId
// };
// }
const [tickets, total] = await Promise.all([
this.prisma.ticket.findMany({
where,
@@ -61,19 +68,20 @@ export class TicketsService {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
seats: { include: { seat: { include: { coach: true } } } },
},
},
},
seat: { include: { coach: { include: { coachType: true } } } },
} as any,
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
}),
}) as any,
this.prisma.ticket.count({ where }),
]);
const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
const iamUserIds = tickets.map((t: any) => t.booking?.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
@@ -83,34 +91,49 @@ export class TicketsService {
const iamMap = new Map(iamRows.map(r => [r.id, r]));
return {
items: tickets.map((t) => {
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
items: tickets.map((t: any) => {
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
const passengerInfo = iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
: { fullName: 'Guest', email: t.booking?.contactEmail, phone: null };
return {
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
passengerName: t.passengerName,
leg: t.leg,
booking: {
id: t.booking.id,
bookingRef: t.booking.bookingRef,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
id: t.booking?.id,
bookingRef: t.booking?.bookingRef,
status: t.booking?.status,
bookingType: t.booking?.bookingType,
returnLegStatus: t.booking?.returnLegStatus ?? null,
outboundBoardedAt: t.booking?.outboundBoardedAt ?? null,
returnBoardedAt: t.booking?.returnBoardedAt ?? null,
totalMinor: t.booking?.totalMinor,
currency: t.booking?.currency,
displayCurrency: t.booking?.displayCurrency,
displayTotalMinor: t.booking?.displayTotalMinor,
passenger: passengerInfo,
contactEmail: t.booking.contactEmail,
contactPhone: t.booking.contactPhone,
returnSchedule: (t.booking as any).returnSchedule ?? null,
contactEmail: t.booking?.contactEmail,
contactPhone: t.booking?.contactPhone,
returnSchedule: t.booking?.returnSchedule ?? null,
seats: t.booking?.seats ?? [],
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
schedule: t.booking?.schedule,
seat: t.seat ? {
id: t.seat.id,
seatNumber: t.seat.seatNumber,
coach: t.seat.coach ? {
id: t.seat.coach.id,
number: t.seat.coach.number,
coachType: t.seat.coach.coachType ? {
id: t.seat.coach.coachType.id,
name: t.seat.coach.coachType.name,
type: t.seat.coach.coachType.type,
} : null,
} : null,
} : null,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
@@ -136,7 +159,7 @@ export class TicketsService {
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// No payment intent record at all
if (!booking.paymentIntent) {
if (!(booking as any).paymentIntent) {
throw new HttpException(
{ status: 'error', message: 'Payment not completed', code: 400 },
HttpStatus.BAD_REQUEST,
@@ -144,21 +167,19 @@ export class TicketsService {
}
// Payment intent exists but not yet succeeded
if (booking.paymentIntent.status !== 'SUCCEEDED') {
if ((booking as any).paymentIntent.status !== 'SUCCEEDED') {
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Payment status: ${booking.paymentIntent.status}`,
detail: `Payment status: ${(booking as any).paymentIntent.status}`,
},
HttpStatus.BAD_REQUEST,
);
}
// Booking not in CONFIRMED state — could be a webhook delivery failure.
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
// self-heal here rather than rejecting a legitimately paid booking.
if (booking.status !== 'CONFIRMED') {
if (booking.status === 'PENDING_PAYMENT') {
this.logger.warn(
@@ -181,154 +202,282 @@ export class TicketsService {
}
}
// Build a compact multi-leg payload for the QR so gate scanners see all legs
const legSummary = this.buildLegSummary(booking);
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
legs: legSummary,
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
// Delete existing tickets if any
await this.prisma.ticket.deleteMany({ where: { bookingId } });
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
// Generate one ticket per unique passenger (grouped by passengerName)
const tickets = [];
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
// Group seats by passenger
const passengerSeatsMap = new Map<string, any[]>();
for (const bookingSeat of (booking as any).seats) {
const key = bookingSeat.passengerName;
if (!passengerSeatsMap.has(key)) {
passengerSeatsMap.set(key, []);
}
passengerSeatsMap.get(key)!.push(bookingSeat);
}
// Create one ticket per passenger
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
// Use first seat for primary data
const primarySeat = passengerSeats[0];
// Build passenger QR data with all legs included
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
seat: ps.seat.seatNumber,
coach: ps.seat.coach.number,
leg: ps.leg || 1,
scheduleId: ps.scheduleId || booking.scheduleId,
})),
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
const ticket = await this.prisma.ticket.create({
data: {
bookingId,
bookingRef: booking.bookingRef,
passengerName,
seatId: primarySeat.seatId,
leg: primarySeat.leg || 1,
scheduleId: primarySeat.scheduleId || booking.scheduleId,
qrPayload,
barcodePayload,
} as any,
});
tickets.push(ticket);
}
// Block all seats across all legs
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
}).catch(() => null);
}
return { ...ticket, legs: legSummary };
return { tickets, totalTickets: tickets.length };
}
private buildLegSummary(booking: any) {
const seatsByLeg = new Map<number, any[]>();
for (const bs of booking.seats) {
const leg = bs.leg ?? 1;
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
seatsByLeg.get(leg)!.push(bs);
}
return Array.from(seatsByLeg.entries())
.sort(([a], [b]) => a - b)
.map(([leg, seats]) => ({
leg,
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
passengers: seats.map(bs => ({
name: bs.passengerName,
category: bs.passengerCategory,
coach: bs.seat?.coach?.number,
seat: bs.seat?.seatNumber,
fareMinor: bs.fareMinor,
})),
}));
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
async updateSeats(bookingId: string, seatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
include: { tickets: true, seats: true } as any
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
for (const ticket of (booking as any).tickets) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
where: { reason: { contains: ticket.id } }
});
}
// Remove old booking seats
// Delete existing tickets
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Update booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
// Create new seat assignments (simplified)
for (let i = 0; i < seatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
seatId: seatIds[i],
passengerName: `Passenger ${i + 1}`,
}
leg: 1
} as any
});
}
return { success: true, updatedSeats: newSeatIds.length };
// Generate new tickets
return this.generate(bookingId);
}
async getByMerchantOrderId(merchantOrderId: string) {
const intent = await this.prisma.paymentIntent.findUnique({
const paymentIntent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
select: { bookingId: true },
include: { booking: { include: { tickets: true } as any } } as any
});
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
if (!paymentIntent) throw new NotFoundException('Payment not found');
const booking = (paymentIntent as any).booking;
if (!booking) throw new NotFoundException('Booking not found');
return this.getByRef(booking.bookingRef);
}
async getByRef(ref: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
where: { bookingRef: ref },
include: {
tickets: true,
schedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: true } } } }
} as any
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
if (!booking) throw new NotFoundException('Booking not found');
return {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload,
booking: {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
bookingType: booking.bookingType,
totalMinor: booking.totalMinor,
currency: booking.currency
},
tickets: (booking as any).tickets,
schedule: (booking as any).schedule,
returnSchedule: (booking as any).returnSchedule,
seats: (booking as any).seats
};
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
ticket: true
},
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id,
bookingId: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
fromStationName: booking.schedule.originStation.name,
toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt,
trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number,
seatLabel: seat?.seat.seatNumber,
passengerName: seat?.passengerName,
priceMinor: booking.totalMinor,
currency: booking.currency,
qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
try {
// Extract booking reference from QR code if it's JSON
let bookingRef = qrCodeOrRef;
try {
const qrData = JSON.parse(qrCodeOrRef);
if (qrData.ref) {
bookingRef = qrData.ref;
}
} catch {
// Not JSON, treat as booking reference
}
// Get booking and ticket info
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) {
throw new NotFoundException('Ticket not found');
}
if (booking.status !== 'CONFIRMED') {
throw new BadRequestException('Ticket is not confirmed');
}
const ticket = (booking as any).tickets[0];
if (!ticket) {
throw new NotFoundException('No ticket found for this booking');
}
// Check if ticket date matches today
const today = new Date();
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
if ((booking as any).schedule?.departureAt) {
const departureDate = new Date((booking as any).schedule.departureAt);
const departureDateStr = departureDate.toISOString().split('T')[0];
// Check if ticket is for today
if (departureDateStr !== todayDateStr) {
if (departureDateStr < todayDateStr) {
throw new BadRequestException('Ticket has expired - departure date has passed');
} else {
throw new BadRequestException('Ticket is for a future date - cannot board early');
}
}
// Additional check: ticket expires 4 hours after departure time
const departureTime = new Date((booking as any).schedule.departureAt);
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
if (today > expiryTime) {
throw new BadRequestException('Ticket has expired - boarding window closed');
}
}
// Use existing validation logic to handle round trips properly
const result = await this.validate(bookingRef, validatorId, gateId);
// Get seat information
const seatInfo = (booking as any).seats[0];
const seatNumber = seatInfo?.seat?.seatNumber || 'N/A';
const coachNumber = seatInfo?.seat?.coach?.number || 'N/A';
// Send notifications after successful boarding
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
return {
success: true,
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
boarding: {
ticketId: ticket.id,
bookingRef: booking.bookingRef,
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
route: `${(booking as any).schedule?.originStation?.name || 'N/A'}${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
seat: seatNumber,
coach: coachNumber,
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
departureTime: (booking as any).schedule?.departureAt,
boardedAt: result.validatedAt,
leg: result.leg || 'OUTBOUND',
bookingType: booking.bookingType,
isRoundTrip: booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'ROUND_TRIP_TRANSIT',
},
};
} catch (error) {
// Return structured error for the UI
const errorMessage = error instanceof Error ? error.message : 'Boarding failed';
const errorCode = error instanceof BadRequestException ? 'VALIDATION_ERROR'
: error instanceof NotFoundException ? 'NOT_FOUND'
: 'SYSTEM_ERROR';
return {
success: false,
error: errorMessage,
errorCode,
};
}
}
private async sendBoardingNotifications(booking: any, ticket: any, leg: string) {
try {
const passengerName = booking.seats?.[0]?.passengerName || ticket.passengerName || 'Passenger';
const contactEmail = booking.contactEmail;
const contactPhone = booking.contactPhone;
if (!contactEmail && !contactPhone) {
this.logger.warn(`No contact details found for booking ${booking.bookingRef}`);
return;
}
const routeInfo = `${booking.schedule?.originStation?.name}${booking.schedule?.destinationStation?.name}`;
const trainName = booking.schedule?.train?.name || booking.schedule?.train?.number;
const departureTime = booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : 'N/A';
const legText = leg === 'RETURN' ? 'Return' : 'Outbound';
// Use the existing sendBoardingPassNotification method
await this.notifications.sendBoardingPassNotification({
passengerId: booking.passengerId || null,
contactEmail,
contactPhone,
bookingRef: booking.bookingRef,
leg,
booking,
ticket,
});
} catch (error: any) {
this.logger.error('Error sending boarding notifications:', error);
}
}
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
@@ -343,11 +492,11 @@ export class TicketsService {
const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
const type = booking.bookingType;
const now = new Date();
const now = new Date();
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') {
@@ -389,92 +538,34 @@ export class TicketsService {
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
throw new BadRequestException('Outbound leg already used');
throw new BadRequestException('Outbound leg already validated');
}
bookingData.outboundBoardedAt = now;
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
} else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
throw new BadRequestException('Return leg already used');
throw new BadRequestException('Return leg already validated');
}
bookingData.returnBoardedAt = now;
} else {
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
}
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
if (type === 'ROUND_TRIP_TRANSIT') {
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
const resolvedLeg = (leg ?? '').toUpperCase();
if (!validLegs.includes(resolvedLeg)) {
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
const bookingData: Record<string, any> = {};
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
bookingData.outboundBoardedAt = now;
}
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
bookingData.returnBoardedAt = now;
}
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// Fallback for unknown booking types — single scan
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
throw new BadRequestException(`Unsupported booking type: ${type}`);
}
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
this.prisma.booking.findUnique({
where: { id: booking.id },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
}).then((enriched) => {
if (!enriched) return;
this.notifications.sendBoardingPassNotification({
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
contactEmail: (enriched as any).contactEmail ?? null,
contactPhone: (enriched as any).contactPhone ?? null,
bookingRef: enriched.bookingRef,
leg,
booking: enriched,
ticket,
}).catch(() => null);
}).catch(() => null);
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
// TODO: Implement notification logic
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
}
async getValidationLogs(ticketId: string) {
@@ -488,111 +579,57 @@ export class TicketsService {
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
passenger: { select: { id: true, iamUserId: true } },
},
} as any,
});
return bookings.map((b) => ({
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.seatNumber,
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
ticketId: (b as any).tickets?.[0]?.id,
passengerName: (b as any).seats[0]?.passengerName,
seatLabel: (b as any).seats[0]?.seat.seatNumber,
coachLabel: (b as any).seats[0]?.seat.coach.number,
qrPayload: (b as any).tickets?.[0]?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
validatedAt: (b as any).tickets?.[0]?.validatedAt,
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
const processedRefs = new Set<string>();
for (const v of validations) {
const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
processedRefs.add(dedupKey);
const results = [];
for (const validation of validations) {
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
if (!booking) {
results.failed++;
results.errors.push(`Booking ${v.bookingRef} not found`);
continue;
}
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) {
results.failed++;
results.errors.push(`Ticket for ${v.bookingRef} not found`);
continue;
}
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++;
continue;
}
// For multi-leg bookings, check per-leg duplication
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLeg && offlineLeg) {
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
if (existingLogs.some(l => l.leg === offlineLeg)) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
const result = await this.validate(
validation.bookingRef,
validation.validatorId,
validation.gateId,
validation.leg
);
results.push({
bookingRef: validation.bookingRef,
success: true,
result
});
await this.prisma.gateValidationLog.create({
data: {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
} as any,
} catch (error) {
results.push({
bookingRef: validation.bookingRef,
success: false,
error: error instanceof Error ? error.message : 'Validation failed'
});
// update boarding timestamps for multi-leg bookings
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLegBooking && offlineLeg) {
const bookingData: Record<string, any> = {};
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
if (Object.keys(bookingData).length) {
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
}
}
results.success++;
} catch (err) {
results.failed++;
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return results;
return {
processed: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results
};
}
async delete(id: string) {
@@ -618,4 +655,4 @@ export class TicketsService {
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
}
}
}