mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
Merge branch 'dev' into freight/develop
This commit is contained in:
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AuditController {
|
||||
constructor(private auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@ApiOperation({
|
||||
summary: 'Get audit logs',
|
||||
description: 'Retrieve system audit logs with optional filtering',
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
|
||||
async getLogs(
|
||||
@Query('search') search?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('entityType') entityType?: string,
|
||||
) {
|
||||
const filters = {
|
||||
search: search || undefined,
|
||||
action: action || undefined,
|
||||
entityType: entityType || undefined,
|
||||
};
|
||||
|
||||
const items = await this.auditService.getLogs(filters);
|
||||
return { items };
|
||||
}
|
||||
|
||||
@Get('logs/:id')
|
||||
@ApiOperation({ summary: 'Get audit log by ID' })
|
||||
async getLog(@Param('id') id: string) {
|
||||
return this.auditService.getLog(id);
|
||||
}
|
||||
}
|
||||
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, HttpModule],
|
||||
controllers: [AuditController],
|
||||
})
|
||||
export class AuditModuleFeature {}
|
||||
@@ -14,6 +14,18 @@ export class PassengerInputDto {
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
|
||||
}
|
||||
|
||||
export class RoundTripPassengerDto {
|
||||
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
|
||||
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
|
||||
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@ApiProperty() @IsString() passengerId: string;
|
||||
@ApiProperty() @IsString() scheduleId: string;
|
||||
@@ -29,6 +41,29 @@ export class CreateBookingDto {
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
export class CreateRoundTripBookingDto {
|
||||
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
|
||||
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
|
||||
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
|
||||
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
|
||||
|
||||
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
|
||||
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
|
||||
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
|
||||
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
|
||||
|
||||
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
|
||||
|
||||
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
export class ModifyBookingDto {
|
||||
@ApiProperty() @IsString() bookingRef: string;
|
||||
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -296,7 +296,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
|
||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
@@ -363,8 +363,60 @@ export class BookingsService {
|
||||
segmentRoute?: string,
|
||||
fullRoute?: string,
|
||||
nationality?: string,
|
||||
originStopSeq?: number,
|
||||
destStopSeq?: number,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// Get schedule with route info
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { route: true },
|
||||
});
|
||||
|
||||
// Try segment fare rule first (most specific) if route info available
|
||||
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
|
||||
// Try with nationality first
|
||||
const segmentFare = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: nationality || null,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentFare) {
|
||||
return segmentFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// If no segment fare with nationality, try without nationality filter
|
||||
if (nationality) {
|
||||
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: null,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (segmentFareAny) return segmentFareAny.baseFareMinor;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to fare rules if no segment fare found
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CurrenciesService } from './currencies.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('Currencies')
|
||||
@Controller('currencies')
|
||||
export class CurrenciesController {
|
||||
constructor(private currenciesService: CurrenciesService) {}
|
||||
|
||||
@Get()
|
||||
getAllCurrencies() {
|
||||
return this.currenciesService.getAllCurrencies();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@HttpCode(201)
|
||||
createCurrency(@Body() dto: CreateCurrencyDto) {
|
||||
return this.currenciesService.createCurrency(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
|
||||
return this.currenciesService.updateCurrency(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
deleteCurrency(@Param('id') id: string) {
|
||||
return this.currenciesService.deleteCurrency(id);
|
||||
}
|
||||
|
||||
@Post('sync-rates')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@HttpCode(200)
|
||||
syncRates() {
|
||||
return this.currenciesService.syncExchangeRates();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsString, IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class CreateCurrencyDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
baseCurrencyCode?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.0001)
|
||||
exchangeRate: number;
|
||||
}
|
||||
|
||||
export class UpdateCurrencyDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
symbol?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0.0001)
|
||||
exchangeRate?: number;
|
||||
}
|
||||
|
||||
export class CurrencyResponseDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
baseCurrencyCode: string;
|
||||
exchangeRate: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CurrenciesController } from './currencies.controller';
|
||||
import { CurrenciesService } from './currencies.service';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
controllers: [CurrenciesController],
|
||||
providers: [CurrenciesService],
|
||||
exports: [CurrenciesService],
|
||||
})
|
||||
export class CurrenciesModule {}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CurrenciesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getAllCurrencies() {
|
||||
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||
distinct: ['toCurrency'],
|
||||
orderBy: { toCurrency: 'asc' },
|
||||
});
|
||||
|
||||
return rates.map(rate => ({
|
||||
id: rate.id,
|
||||
code: rate.toCurrency,
|
||||
name: this.getCurrencyName(rate.toCurrency),
|
||||
symbol: this.getCurrencySymbol(rate.toCurrency),
|
||||
baseCurrencyCode: rate.fromCurrency,
|
||||
exchangeRate: Number(rate.rate),
|
||||
isActive: true,
|
||||
createdAt: rate.createdAt,
|
||||
updatedAt: rate.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async createCurrency(dto: CreateCurrencyDto) {
|
||||
const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto;
|
||||
|
||||
if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) {
|
||||
throw new BadRequestException('Unsupported currency code');
|
||||
}
|
||||
|
||||
if (exchangeRate <= 0) {
|
||||
throw new BadRequestException('Exchange rate must be positive');
|
||||
}
|
||||
|
||||
const rate = await this.prisma.currencyExchangeRate.create({
|
||||
data: {
|
||||
fromCurrency: baseCurrencyCode as any,
|
||||
toCurrency: code.toUpperCase() as any,
|
||||
rate: exchangeRate,
|
||||
source: 'MANUAL',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: rate.id,
|
||||
code: rate.toCurrency,
|
||||
name,
|
||||
symbol,
|
||||
baseCurrencyCode: rate.fromCurrency,
|
||||
exchangeRate: Number(rate.rate),
|
||||
isActive: true,
|
||||
createdAt: rate.createdAt,
|
||||
updatedAt: rate.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async updateCurrency(id: string, dto: UpdateCurrencyDto) {
|
||||
const existing = await this.prisma.currencyExchangeRate.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Currency not found');
|
||||
}
|
||||
|
||||
if (dto.exchangeRate !== undefined && dto.exchangeRate <= 0) {
|
||||
throw new BadRequestException('Exchange rate must be positive');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.currencyExchangeRate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
rate: dto.exchangeRate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
code: updated.toCurrency,
|
||||
name: dto.name || this.getCurrencyName(updated.toCurrency),
|
||||
symbol: dto.symbol || this.getCurrencySymbol(updated.toCurrency),
|
||||
baseCurrencyCode: updated.fromCurrency,
|
||||
exchangeRate: Number(updated.rate),
|
||||
isActive: true,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteCurrency(id: string) {
|
||||
const existing = await this.prisma.currencyExchangeRate.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Currency not found');
|
||||
}
|
||||
|
||||
await this.prisma.currencyExchangeRate.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { message: 'Currency deleted successfully' };
|
||||
}
|
||||
|
||||
async syncExchangeRates() {
|
||||
return { message: 'Exchange rates synced successfully', synced: 0 };
|
||||
}
|
||||
|
||||
private getCurrencyName(code: string): string {
|
||||
const names: Record<string, string> = {
|
||||
ETB: 'Ethiopian Birr',
|
||||
USD: 'US Dollar',
|
||||
DJF: 'Djiboutian Franc',
|
||||
};
|
||||
return names[code] || code;
|
||||
}
|
||||
|
||||
private getCurrencySymbol(code: string): string {
|
||||
const symbols: Record<string, string> = {
|
||||
ETB: 'Br',
|
||||
USD: '$',
|
||||
DJF: 'Fdj',
|
||||
};
|
||||
return symbols[code] || code;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Post, Get, Query, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FareEngineService } from './fare-engine.service';
|
||||
@@ -16,23 +16,10 @@ export class FareEngineController {
|
||||
@Post('calculate')
|
||||
@ApiOperation({
|
||||
summary: 'Calculate fare for a journey leg',
|
||||
description: `Computes fare using the formula:
|
||||
|
||||
**Fare = totalKm × ratePerKm × exchangeRate**
|
||||
|
||||
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
|
||||
- \`ratePerKm\` — \`SeatClass.basePrice\` (stored in ETB minor units per km)
|
||||
- \`exchangeRate\` — derived from passenger nationality:
|
||||
- **Ethiopian** → ETB (rate = 1.0)
|
||||
- **Djiboutian** → DJF (rate ≈ 3.25)
|
||||
- **Other / unspecified** → USD (rate ≈ 0.018)
|
||||
|
||||
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
|
||||
5% tax applied after promo discount.
|
||||
Returns a full breakdown including a human-readable calculation trace.`,
|
||||
description: `Computes fare using the formula:\n\n**Fare = totalKm × ratePerKm × exchangeRate**`,
|
||||
})
|
||||
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid route/station combination' })
|
||||
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
|
||||
calculate(@Body() dto: FareCalculateDto) {
|
||||
return this.service.calculate(dto);
|
||||
@@ -41,15 +28,14 @@ Returns a full breakdown including a human-readable calculation trace.`,
|
||||
@Get('compare')
|
||||
@ApiOperation({
|
||||
summary: 'Compare fares across all seat classes for a route leg',
|
||||
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
|
||||
})
|
||||
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
|
||||
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
|
||||
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
|
||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
|
||||
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
|
||||
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
|
||||
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
@ApiQuery({ name: 'adultCount', required: false, type: Number })
|
||||
@ApiQuery({ name: 'childCount', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: 'Array of fare breakdowns' })
|
||||
compareClasses(
|
||||
@Query('routeId') routeId: string,
|
||||
@Query('originStationId') originStationId: string,
|
||||
@@ -67,6 +53,8 @@ Returns a full breakdown including a human-readable calculation trace.`,
|
||||
childCount ? parseInt(childCount) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ApiTags('Config')
|
||||
@@ -77,18 +65,10 @@ export class ConfigController {
|
||||
@Get('fayda-status')
|
||||
@ApiOperation({
|
||||
summary: 'Check Verifayda 2.0 configuration status',
|
||||
description: 'Returns whether Verifayda integration is enabled and ready to use'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Verifayda status retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
enabled: true,
|
||||
mode: 'production',
|
||||
apiUrl: 'https://api.verifayda.gov.et/v2'
|
||||
}
|
||||
}
|
||||
})
|
||||
getFaydaStatus() {
|
||||
const faydaConfig = this.configService.get<FaydaConfig>('fayda');
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { FareEngineController, ConfigController } from './fare-engine.controller';
|
||||
import { FareEngineService } from './fare-engine.service';
|
||||
import { CurrencyController } from './currency.controller';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule],
|
||||
imports: [HttpModule, CurrencyModule],
|
||||
controllers: [FareEngineController, CurrencyController, ConfigController],
|
||||
providers: [FareEngineService],
|
||||
exports: [FareEngineService],
|
||||
|
||||
@@ -47,14 +47,22 @@ export class FareEngineService {
|
||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
|
||||
// Premium and insurance fees applied per passenger
|
||||
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
|
||||
const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0;
|
||||
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
|
||||
|
||||
const adultCount = dto.adultCount ?? 1;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
const freeChildrenCount = Math.min(childCount, 1);
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
|
||||
const subtotalMinor =
|
||||
baseFarePerPassengerMinor * adultCount +
|
||||
baseFarePerPassengerMinor * paidChildrenCount;
|
||||
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
||||
// First child is free, but pays premium and insurance
|
||||
const adultSubtotal = farePerPassengerMinor * adultCount;
|
||||
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
|
||||
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
|
||||
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
|
||||
|
||||
let discountMinor = 0;
|
||||
let promoLabel = 'none';
|
||||
@@ -85,12 +93,20 @@ export class FareEngineService {
|
||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
|
||||
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
|
||||
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
|
||||
`Premium/pax: ${premiumPerPassenger} ETB minor`,
|
||||
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
|
||||
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
|
||||
``,
|
||||
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
|
||||
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
|
||||
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
|
||||
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
|
||||
``,
|
||||
`Subtotal: ${subtotalMinor} ETB minor`,
|
||||
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
|
||||
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
|
||||
`Tax (5%): +${taxMinor} ETB minor`,
|
||||
`Total (ETB): ${totalEtbMinor} ETB minor`,
|
||||
``,
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||
@@ -104,6 +120,9 @@ export class FareEngineService {
|
||||
totalDistanceKm,
|
||||
ratePerKmMinor,
|
||||
baseFarePerPassengerMinor,
|
||||
premiumPerPassenger,
|
||||
insurancePerPassenger,
|
||||
farePerPassengerMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
freeChildrenCount,
|
||||
@@ -142,7 +161,6 @@ export class FareEngineService {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
|
||||
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
@@ -160,7 +178,6 @@ export class FareEngineService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Calculate fares for all active seat classes on a schedule. */
|
||||
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
@@ -168,7 +185,6 @@ export class FareEngineService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// ── Route-based calculation (fare engine) ────────────────────────────────
|
||||
if (schedule.routeId) {
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
@@ -190,7 +206,6 @@ export class FareEngineService {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
// ── Fallback: FareRule records scoped to this schedule ───────────────────
|
||||
const now = new Date();
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
|
||||
@@ -158,7 +158,34 @@ export class FleetController {
|
||||
@ApiOperation({ summary: 'List coaches with seat status summary' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
|
||||
@ApiResponse({ status: 200, description: 'Array of coaches' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Array of coaches',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
coachType: {
|
||||
id: 'coach-type-uuid',
|
||||
code: 'sleeper',
|
||||
name: 'Sleeper Coach'
|
||||
},
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
status: 'ACTIVE',
|
||||
totalSeats: 60,
|
||||
availableSeats: 45,
|
||||
occupiedSeats: 15,
|
||||
blockedSeats: 0,
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
listCoaches(
|
||||
@Query('status') status?: string,
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@@ -173,7 +200,40 @@ export class FleetController {
|
||||
@Get('coaches/:id')
|
||||
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Coach detail with seats by row',
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
coachType: {
|
||||
id: 'coach-type-uuid',
|
||||
code: 'sleeper',
|
||||
name: 'Sleeper Coach'
|
||||
},
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
status: 'ACTIVE',
|
||||
seats: [
|
||||
{
|
||||
id: 'seat-uuid-1',
|
||||
seatNumber: '1A',
|
||||
status: 'AVAILABLE',
|
||||
class: {
|
||||
id: 'class-uuid',
|
||||
name: 'Economy',
|
||||
baseFareMinor: 5000
|
||||
}
|
||||
}
|
||||
],
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
getCoach(@Param('id') id: string) {
|
||||
return this.service.getCoach(id);
|
||||
@@ -182,7 +242,23 @@ export class FleetController {
|
||||
@Post('coaches')
|
||||
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
||||
@ApiBody({ type: CreateCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach created' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Coach created',
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
||||
createCoach(@Body() dto: CreateCoachDto) {
|
||||
return this.service.createCoach(dto);
|
||||
@@ -192,7 +268,23 @@ export class FleetController {
|
||||
@ApiOperation({ summary: 'Update coach properties' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiBody({ type: UpdateCoachDto })
|
||||
@ApiResponse({ status: 200, description: 'Coach updated' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Coach updated',
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
|
||||
return this.service.updateCoach(id, dto);
|
||||
@@ -201,7 +293,7 @@ export class FleetController {
|
||||
@Delete('coaches/:id')
|
||||
@ApiOperation({ summary: 'Delete a coach' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
||||
@ApiResponse({ status: 200, description: 'Coach deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
deleteCoach(@Param('id') id: string) {
|
||||
return this.service.deleteCoach(id);
|
||||
|
||||
@@ -48,19 +48,16 @@ function buildSeats(coachId: string, coachNumber: string, arrangement: string, c
|
||||
const col = cols[ci];
|
||||
let bedPosition = null;
|
||||
|
||||
// Set bedPosition for bed coaches based on seat number cycling
|
||||
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
|
||||
if (isBedCoach) {
|
||||
if (totalCols === 3) {
|
||||
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
|
||||
const posMod = ((seatNumber - 1) % 3);
|
||||
if (posMod === 0) bedPosition = 'lower';
|
||||
else if (posMod === 1) bedPosition = 'middle';
|
||||
else if (posMod === 2) bedPosition = 'upper';
|
||||
// Economy bed (3-row cycle): upper, middle, lower
|
||||
if (row % 3 === 1) bedPosition = 'upper';
|
||||
else if (row % 3 === 2) bedPosition = 'middle';
|
||||
else bedPosition = 'lower';
|
||||
} else if (totalCols === 2) {
|
||||
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
|
||||
const posMod = ((seatNumber - 1) % 2);
|
||||
if (posMod === 0) bedPosition = 'lower';
|
||||
else if (posMod === 1) bedPosition = 'upper';
|
||||
// VIP bed (2-row cycle): upper, lower
|
||||
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +250,7 @@ export class FleetService {
|
||||
return this.prisma.coach.findMany({
|
||||
where,
|
||||
include: { coachType: true },
|
||||
orderBy: { number: 'asc' },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,10 +260,18 @@ export class FleetService {
|
||||
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
|
||||
}
|
||||
|
||||
// Get the next sequence number for this coach type
|
||||
const lastCoach = await this.prisma.coach.findFirst({
|
||||
where: { coachTypeId: dto.coachTypeId },
|
||||
orderBy: { sequence: 'desc' },
|
||||
});
|
||||
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
|
||||
|
||||
const coach = await this.prisma.coach.create({
|
||||
data: {
|
||||
coachTypeId: dto.coachTypeId,
|
||||
number: dto.number,
|
||||
sequence: nextSequence,
|
||||
arrangement: dto.arrangement,
|
||||
capacity: dto.capacity,
|
||||
status: dto.status || 'ACTIVE',
|
||||
@@ -301,33 +306,6 @@ export class FleetService {
|
||||
async deleteCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Get all seat IDs for this coach
|
||||
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
|
||||
const seatIds = seats.map(s => s.id);
|
||||
|
||||
// Delete in order of foreign key dependencies
|
||||
if (seatIds.length > 0) {
|
||||
// 1. Delete seat blocks (references seats)
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 2. Delete ticket seats (references seats)
|
||||
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 3. Delete booking seats (references seats)
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 4. Delete journey segments with these seats
|
||||
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
}
|
||||
|
||||
// 5. Delete all associated seats
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
// 6. Delete coach assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
|
||||
|
||||
// 7. Finally delete the coach
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class SendEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
html?: string;
|
||||
templateKey?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class SendMessage {
|
||||
to: string;
|
||||
message: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export class BulkMessagesDto {
|
||||
messages: SendMessage[];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { SendEmail } from './dtos/email.dto';
|
||||
|
||||
@Injectable()
|
||||
export class EmailClientService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(EmailClientService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('EMAIL_SERVICE')
|
||||
private readonly emailServiceClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.emailServiceClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to Email service'))
|
||||
.catch((err) => this.logger.error('Error connecting to Email service', err));
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail) {
|
||||
this.emailServiceClient.emit('send-email', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { TestNotificationDto } from './notifications.dto';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { SendEmail } from './dtos/email.dto';
|
||||
import { SendMessage } from './dtos/sms.dto';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@Controller('notifications')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class NotificationsController {
|
||||
constructor(private service: NotificationsService) {}
|
||||
constructor(
|
||||
private service: NotificationsService,
|
||||
private emailClient: EmailClientService,
|
||||
private smsClient: SmsClientService,
|
||||
) {}
|
||||
|
||||
@Get(':passengerId')
|
||||
@ApiOperation({ summary: 'Get notifications for passenger' })
|
||||
@@ -30,6 +38,24 @@ export class NotificationsController {
|
||||
return this.service.markAllRead(id);
|
||||
}
|
||||
|
||||
@Post('send/email')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
|
||||
@ApiBody({ type: SendEmail })
|
||||
sendEmail(@Body() dto: SendEmail) {
|
||||
return this.emailClient.sendEmail(dto);
|
||||
}
|
||||
|
||||
@Post('send/sms')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
|
||||
@ApiBody({ type: SendMessage })
|
||||
sendSms(@Body() dto: SendMessage) {
|
||||
return this.smsClient.sendSms(dto);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule.register({ timeout: 10_000 })],
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ClientsModule.registerAsync([
|
||||
{
|
||||
name: 'EMAIL_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'SMS_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
|
||||
exports: [NotificationsService],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
],
|
||||
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
||||
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
|
||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||
|
||||
@@ -13,13 +15,13 @@ export class NotificationsService {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailAdapter: EmailAdapter,
|
||||
private smsAdapter: SmsAdapter,
|
||||
private emailClient: EmailClientService,
|
||||
private smsClient: SmsClientService,
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['EMAIL', this.emailAdapter as NotificationChannel],
|
||||
['SMS', this.smsAdapter as NotificationChannel],
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
|
||||
['PUSH', this.pushAdapter as NotificationChannel],
|
||||
]);
|
||||
}
|
||||
@@ -102,11 +104,11 @@ export class NotificationsService {
|
||||
});
|
||||
|
||||
if (passenger?.user) {
|
||||
await this.emailAdapter.send(
|
||||
passenger.user.email,
|
||||
this.sanitize(dto.title),
|
||||
this.sanitize(dto.body),
|
||||
);
|
||||
await this.emailClient.sendEmail({
|
||||
to: passenger.user.email,
|
||||
subject: this.sanitize(dto.title),
|
||||
body: this.sanitize(dto.body),
|
||||
});
|
||||
}
|
||||
|
||||
return notification;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SmsClientService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SmsClientService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('SMS_SERVICE')
|
||||
private readonly smsClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.smsClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to SMS service'))
|
||||
.catch((err) => this.logger.error('Error connecting to SMS service', err));
|
||||
}
|
||||
|
||||
async sendSms(dto: SendMessage) {
|
||||
this.smsClient.emit('send-sms', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto) {
|
||||
this.smsClient.emit('ozeking-bulk-sms', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -47,17 +47,9 @@ export class PassengersService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
nationalId: true,
|
||||
nationality: true,
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true,
|
||||
@@ -69,19 +61,30 @@ export class PassengersService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(passenger => ({
|
||||
id: passenger.id,
|
||||
fullName: passenger.user.fullName,
|
||||
email: passenger.user.email,
|
||||
phone: passenger.user.phone,
|
||||
nationalId: passenger.user.nationalId,
|
||||
nationality: passenger.user.nationality,
|
||||
verified: !!passenger.user.nationalId,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
})),
|
||||
items: items.map(passenger => {
|
||||
const user = passenger.user as any;
|
||||
return {
|
||||
id: passenger.id,
|
||||
userId: passenger.userId,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
nationalId: user.nationalId,
|
||||
nationality: user.nationality,
|
||||
dateOfBirth: user.dateOfBirth ?? null,
|
||||
gender: user.gender ?? null,
|
||||
passportNumber: user.passportNumber,
|
||||
passportCountry: user.passportCountry ?? null,
|
||||
verified: !!user.nationalId,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
loyalty: passenger.loyalty,
|
||||
wallet: passenger.wallet,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -95,9 +98,19 @@ export class PassengersService {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
|
||||
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
||||
user: true,
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
}
|
||||
},
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
travelerProfiles: true,
|
||||
savedRoutes: true,
|
||||
},
|
||||
});
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
@@ -108,14 +121,35 @@ export class PassengersService {
|
||||
phone: passenger.user.phone,
|
||||
createdAt: passenger.createdAt,
|
||||
bookings: passenger.bookings.map((b) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalFare: b.totalMinor / 100,
|
||||
createdAt: b.createdAt,
|
||||
trip: {
|
||||
number: b.schedule.train.number,
|
||||
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
|
||||
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
||||
origin: {
|
||||
id: b.schedule.originStation.id,
|
||||
name: b.schedule.originStation.name,
|
||||
code: b.schedule.originStation.code,
|
||||
city: b.schedule.originStation.city
|
||||
},
|
||||
destination: {
|
||||
id: b.schedule.destinationStation.id,
|
||||
name: b.schedule.destinationStation.name,
|
||||
code: b.schedule.destinationStation.code,
|
||||
city: b.schedule.destinationStation.city
|
||||
},
|
||||
departureAt: b.schedule.departureAt,
|
||||
},
|
||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
|
||||
passengers: b.seats.map((bs) => ({
|
||||
fullName: bs.passengerName,
|
||||
seat: {
|
||||
number: bs.seat.seatNumber,
|
||||
coach: bs.seat.coach.number,
|
||||
class: 'N/A'
|
||||
}
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -171,14 +205,25 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
|
||||
return this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
...dto,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
|
||||
getTravelerProfiles(passengerId: string) {
|
||||
return this.prisma.travelerProfile.findMany({ where: { passengerId } });
|
||||
}
|
||||
|
||||
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
|
||||
createSavedRoute(dto: CreateSavedRouteDto) {
|
||||
return this.prisma.savedRoute.create({ data: dto });
|
||||
}
|
||||
|
||||
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
|
||||
getSavedRoutes(passengerId: string) {
|
||||
return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } });
|
||||
}
|
||||
|
||||
async updatePassenger(id: string, dto: any) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
@@ -196,7 +241,7 @@ export class PassengersService {
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
|
||||
user: true,
|
||||
loyalty: true,
|
||||
},
|
||||
});
|
||||
@@ -290,9 +335,7 @@ export class PassengersService {
|
||||
async deletePassenger(id: string) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
await this.prisma.passenger.delete({ where: { id } });
|
||||
return { deleted: true, passengerId: id };
|
||||
return this.prisma.passenger.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async checkPassengerUsage(id: string) {
|
||||
|
||||
@@ -42,13 +42,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
/**
|
||||
* DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet
|
||||
* balance check and debit are skipped and the booking is confirmed + ticket issued as if fully
|
||||
* paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable.
|
||||
* Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env.
|
||||
*/
|
||||
private readonly walletDemoAutoSucceed = true;
|
||||
|
||||
constructor(
|
||||
@@ -136,9 +129,7 @@ export class PaymentsService {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
// Provider methods go through the payment microservice (docs/payment-service §7.1):
|
||||
// it owns the intent, the provider session, and the single webhook per provider.
|
||||
// Re-initiating is safe — the service returns the existing active intent (idempotent).
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
@@ -148,10 +139,8 @@ export class PaymentsService {
|
||||
currency: booking.currency,
|
||||
provider: method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
// PASSENGER-owned browser bounce-back after the hosted page (freight passes its own).
|
||||
// UX only — payment is confirmed by the webhook/mark-paid event, never this redirect.
|
||||
returnUrl: process.env.PAYMENT_RETURN_URL || undefined,
|
||||
failureUrl: process.env.PAYMENT_FAILURE_URL || undefined,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
});
|
||||
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
@@ -168,6 +157,40 @@ export class PaymentsService {
|
||||
}
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
private resolveReturnUrls(method: PaymentMethodType): {
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
} {
|
||||
const perMethod: Partial<
|
||||
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
|
||||
> = {
|
||||
[PaymentMethodType.TELEBIRR]: {
|
||||
returnUrl: process.env.TELEBIRR_RETURN_URL,
|
||||
},
|
||||
[PaymentMethodType.WAAFI]: {
|
||||
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
|
||||
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
|
||||
},
|
||||
[PaymentMethodType.DMONEY]: {
|
||||
returnUrl: process.env.DMONEY_RETURN_URL,
|
||||
},
|
||||
[PaymentMethodType.CBE_BIRR]: {
|
||||
returnUrl: process.env.CBE_RETURN_URL,
|
||||
},
|
||||
[PaymentMethodType.EBIRR]: {
|
||||
returnUrl: process.env.EBIRR_RETURN_URL,
|
||||
},
|
||||
[PaymentMethodType.CARD]: {
|
||||
returnUrl: process.env.CARD_RETURN_URL,
|
||||
},
|
||||
};
|
||||
|
||||
const m = perMethod[method] ?? {};
|
||||
const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
|
||||
const failureUrl =
|
||||
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
|
||||
return { returnUrl, failureUrl };
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
|
||||
@@ -8,7 +8,10 @@ export class ReportsService {
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateTo = new Date(dto.dateTo);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
let data: any;
|
||||
switch (dto.reportType) {
|
||||
@@ -44,14 +47,16 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
|
||||
// Fetch all bookings in date range, regardless of status
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: { in: ['CONFIRMED', 'COMPLETED'] }
|
||||
createdAt: { gte: dateFrom, lte: dateTo }
|
||||
},
|
||||
include: { paymentIntent: true }
|
||||
});
|
||||
|
||||
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
@@ -59,12 +64,25 @@ export class ReportsService {
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Group by date for charts
|
||||
const byDate = bookings.reduce((acc, b) => {
|
||||
const date = b.createdAt.toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenueMinor: totalRevenue,
|
||||
totalRevenue: totalRevenue / 100,
|
||||
currency: 'ETB',
|
||||
byPaymentMethod
|
||||
byPaymentMethod,
|
||||
byDate,
|
||||
cancellationRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +91,7 @@ export class ReportsService {
|
||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: {
|
||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
|
||||
bookings: { include: { seats: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -142,6 +142,14 @@ export class SchedulesController {
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Get(':scheduleId/fares/stored')
|
||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
|
||||
getStoredFares(@Param('scheduleId') scheduleId: string) {
|
||||
return this.service.getFareRules(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { TripStatus, StopStatus } from '@prisma/client';
|
||||
import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client';
|
||||
|
||||
export class PlannedStopTimeDto {
|
||||
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
|
||||
@@ -51,6 +51,7 @@ export class CreateFareRuleDto {
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
|
||||
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string;
|
||||
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||
@@ -64,6 +65,7 @@ export class CreateSegmentFareRuleDto {
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
|
||||
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
|
||||
@@ -329,50 +329,6 @@ export class SchedulesService {
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map(b => b.id);
|
||||
|
||||
if (bookingIds.length > 0) {
|
||||
const paymentIntents = await this.prisma.paymentIntent.findMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const paymentIntentIds = paymentIntents.map(pi => pi.id);
|
||||
|
||||
if (paymentIntentIds.length > 0) {
|
||||
await this.prisma.paymentRefund.deleteMany({
|
||||
where: { paymentIntentId: { in: paymentIntentIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingSeat.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingModification.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingCancellation.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.paymentIntent.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -402,7 +358,7 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.fareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
@@ -415,7 +371,7 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
createSegmentFareRule(dto: any) {
|
||||
const { validFrom, validUntil, ...rest } = dto;
|
||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
@@ -439,7 +395,7 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
updateSegmentFareRule(id: string, dto: any) {
|
||||
const { validFrom, validUntil, ...rest } = dto;
|
||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -451,12 +407,36 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
async getFareRules(scheduleId?: string) {
|
||||
const where: any = {};
|
||||
if (scheduleId) where.tripId = scheduleId;
|
||||
|
||||
return this.prisma.fareRule.findMany({
|
||||
where,
|
||||
include: { seatClass: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
|
||||
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
|
||||
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
async getAllFaresFromEngine(scheduleId: string, nationality?: string) {
|
||||
try {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
||||
|
||||
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
||||
|
||||
@@ -11,17 +11,24 @@ export class SearchController {
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: 'Search trips by origin, destination, date, passengers, and nationality',
|
||||
description: `Finds all train schedules matching search criteria with real-time seat availability.
|
||||
description: `Finds all train schedules matching search criteria with real-time seat availability and coach type options.
|
||||
|
||||
**Coach Type Selection Flow:**
|
||||
- Users browse available coach types (Economy, VIP, etc.)
|
||||
- Each coach type displays available seat classes and base fares
|
||||
- Users select a coach type to proceed to seat selection
|
||||
- At seat selection, users choose specific seat and class (actual price confirmed here)
|
||||
- Final fare may adjust based on seat position/amenities selected
|
||||
|
||||
**Features:**
|
||||
- Any origin→destination stop pair (not just terminals)
|
||||
- Age-based passenger counts (adults ≥5 years, children <5 years)
|
||||
- Nationality filtering (Ethiopian, Djiboutian, Other)
|
||||
- Real-time seat availability per class
|
||||
- Multi-currency fare display
|
||||
- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D
|
||||
- Availability: Segment-based (seat booked A→B is still available B→D)`
|
||||
- Segment-based availability (seat booked A→B still available B→D)`
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
|
||||
@ApiResponse({ status: 200, description: 'Matching schedules with coachTypes array showing available coach types with seat classes and base fares' })
|
||||
searchTrips(@Body() dto: SearchTripsDto) {
|
||||
return this.service.searchTrips(dto);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ export class SearchTripsDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], description: 'Journey type: ONE_WAY or ROUND_TRIP' })
|
||||
@IsOptional() @IsEnum(['ONE_WAY', 'ROUND_TRIP']) journeyType?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@@ -53,4 +59,39 @@ export class FareQuoteDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Return schedule UUID (required for ROUND_TRIP journeys)' })
|
||||
@IsOptional() @IsString() returnScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return origin station ID (required for ROUND_TRIP)' })
|
||||
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return destination station ID (required for ROUND_TRIP)' })
|
||||
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||
}
|
||||
|
||||
export class CoachTypeOptionClass {
|
||||
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
|
||||
baseFareMinor: number;
|
||||
}
|
||||
|
||||
export class CoachTypeOption {
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
|
||||
coachTypeId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
|
||||
coachTypeName: string;
|
||||
|
||||
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
|
||||
coachTypeCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
|
||||
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
|
||||
})
|
||||
classes: CoachTypeOptionClass[];
|
||||
}
|
||||
|
||||
@@ -18,15 +18,57 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const date = new Date(dto.date);
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
|
||||
const outbound = await this.searchSchedules(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const allInbound = await this.searchSchedules(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
const latestOutboundArrival = outbound.length > 0
|
||||
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
|
||||
: Date.now();
|
||||
|
||||
const inbound = allInbound.filter((schedule) =>
|
||||
new Date(schedule.departureAt).getTime() > latestOutboundArrival
|
||||
);
|
||||
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||
}
|
||||
|
||||
return { journeyType: 'ONE_WAY', outbound };
|
||||
}
|
||||
|
||||
private async searchSchedules(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: dto.originStationId } },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
@@ -42,8 +84,8 @@ export class SearchService {
|
||||
const results = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||
|
||||
@@ -61,14 +103,14 @@ export class SearchService {
|
||||
if (seat.bedPosition !== bedPosition) continue;
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) count++;
|
||||
}
|
||||
|
||||
|
||||
if (count > 0) {
|
||||
const matchingClass = seatClassNames.find((className: string) => {
|
||||
const classNameLower = className.toLowerCase();
|
||||
@@ -89,14 +131,14 @@ export class SearchService {
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) availableSeatsInCoach++;
|
||||
}
|
||||
|
||||
|
||||
for (const seatClassName of seatClassNames) {
|
||||
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||
availabilityByClass[seatClassName] += availableSeatsInCoach;
|
||||
@@ -109,11 +151,13 @@ export class SearchService {
|
||||
|
||||
const faresByClass = await this.calculateFaresForSegment(
|
||||
schedule,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.nationality,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
nationality,
|
||||
);
|
||||
|
||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
@@ -150,6 +194,7 @@ export class SearchService {
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,14 +303,14 @@ export class SearchService {
|
||||
.filter((id: any) => id)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (seatClassIds.length === 0) {
|
||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: {
|
||||
where: {
|
||||
isActive: true,
|
||||
id: { in: seatClassIds }
|
||||
},
|
||||
@@ -307,7 +352,7 @@ export class SearchService {
|
||||
|
||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
||||
|
||||
|
||||
if (originStation && destStation) {
|
||||
const segmentRoute = `${originStation.code}-${destStation.code}`;
|
||||
const now = new Date();
|
||||
@@ -341,6 +386,62 @@ export class SearchService {
|
||||
}));
|
||||
}
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
schedule: any,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
|
||||
): Promise<Array<{
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||
}>> {
|
||||
const coachTypeMap = new Map<
|
||||
string,
|
||||
{ coachType: any; classNames: Set<string> }
|
||||
>();
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const coachType = assignment.coach.coachType;
|
||||
if (!coachType) continue;
|
||||
|
||||
if (!coachTypeMap.has(coachType.id)) {
|
||||
coachTypeMap.set(coachType.id, {
|
||||
coachType,
|
||||
classNames: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
const entry = coachTypeMap.get(coachType.id)!;
|
||||
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (const [, { coachType, classNames }] of coachTypeMap) {
|
||||
const classes = Array.from(classNames)
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
return {
|
||||
name: className,
|
||||
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
result.push({
|
||||
coachTypeId: coachType.id,
|
||||
coachTypeName: coachType.name,
|
||||
coachTypeCode: coachType.code,
|
||||
classes,
|
||||
});
|
||||
}
|
||||
|
||||
return result.sort((a, b) => {
|
||||
const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
|
||||
const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
|
||||
return minPriceA - minPriceB;
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultFareForClass(className: string): number {
|
||||
const defaults: Record<string, number> = {
|
||||
'Economy Regular': 35000,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { StationsService } from './stations.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -17,6 +17,28 @@ export class StationsController {
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' })
|
||||
@ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' })
|
||||
@ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Array of stations',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
code: 'AAA',
|
||||
sequence: 1,
|
||||
name: 'Addis Ababa',
|
||||
city: 'Addis Ababa',
|
||||
countryCode: 'ET',
|
||||
lat: 9.0054,
|
||||
lng: 38.7636,
|
||||
timezone: 'Africa/Addis_Ababa',
|
||||
isOperational: true,
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('country') country?: string,
|
||||
@@ -30,18 +52,79 @@ export class StationsController {
|
||||
summary: 'Get station details by ID',
|
||||
description: 'Returns station information including name, code, country, coordinates, and facilities'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Station details',
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
code: 'AAA',
|
||||
sequence: 1,
|
||||
name: 'Addis Ababa',
|
||||
city: 'Addis Ababa',
|
||||
countryCode: 'ET',
|
||||
lat: 9.0054,
|
||||
lng: 38.7636,
|
||||
timezone: 'Africa/Addis_Ababa',
|
||||
isOperational: true,
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create new station' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Station created',
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
code: 'AAA',
|
||||
sequence: 1,
|
||||
name: 'Addis Ababa',
|
||||
city: 'Addis Ababa',
|
||||
countryCode: 'ET',
|
||||
lat: 9.0054,
|
||||
lng: 38.7636,
|
||||
timezone: 'Africa/Addis_Ababa',
|
||||
isOperational: true,
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update station' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Station updated',
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
code: 'AAA',
|
||||
sequence: 1,
|
||||
name: 'Addis Ababa',
|
||||
city: 'Addis Ababa',
|
||||
countryCode: 'ET',
|
||||
lat: 9.0054,
|
||||
lng: 38.7636,
|
||||
timezone: 'Africa/Addis_Ababa',
|
||||
isOperational: true,
|
||||
createdAt: '2024-01-15T10:30:00.000Z',
|
||||
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Station not found' })
|
||||
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
@@ -50,6 +133,8 @@ export class StationsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete station' })
|
||||
@ApiResponse({ status: 200, description: 'Station deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Station not found' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { StationsController } from './stations.controller';
|
||||
import { StationsService } from './stations.service';
|
||||
|
||||
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [StationsController],
|
||||
providers: [StationsService],
|
||||
exports: [StationsService],
|
||||
})
|
||||
export class StationsModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
|
||||
interface StationFilters {
|
||||
@@ -10,7 +12,11 @@ interface StationFilters {
|
||||
|
||||
@Injectable()
|
||||
export class StationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
findAll(filters: StationFilters = {}) {
|
||||
const where: any = {};
|
||||
@@ -33,7 +39,7 @@ export class StationsService {
|
||||
|
||||
return this.prisma.station.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' }
|
||||
orderBy: { sequence: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,20 +49,51 @@ export class StationsService {
|
||||
return s;
|
||||
}
|
||||
|
||||
create(dto: CreateStationDto) {
|
||||
return this.prisma.station.create({ data: dto });
|
||||
async create(dto: CreateStationDto) {
|
||||
const station = await this.prisma.station.create({ data: dto });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Station',
|
||||
entityId: station.id,
|
||||
newData: station,
|
||||
});
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto
|
||||
const oldStation = await this.findOne(id);
|
||||
const updatedStation = await this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: oldStation,
|
||||
newData: updatedStation,
|
||||
});
|
||||
|
||||
return updatedStation;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.delete({ where: { id } });
|
||||
const station = await this.findOne(id);
|
||||
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: station,
|
||||
});
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,17 @@ export class TicketsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('by-order/:merchantOrderId')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket by merchant order ID',
|
||||
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
||||
})
|
||||
getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) {
|
||||
return this.service.getByMerchantOrderId(merchantOrderId);
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -49,12 +49,16 @@ export class TicketsService {
|
||||
booking: {
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
totalMinor: t.booking.totalMinor,
|
||||
currency: t.booking.currency,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
displayTotalMinor: t.booking.displayTotalMinor,
|
||||
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
|
||||
contactEmail: t.booking.contactEmail,
|
||||
},
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
status: t.booking.status,
|
||||
status: t.status,
|
||||
validatedAt: t.validatedAt,
|
||||
createdAt: t.issuedAt,
|
||||
})),
|
||||
@@ -157,9 +161,14 @@ export class TicketsService {
|
||||
return { success: true, updatedSeats: newSeatIds.length };
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
async getByMerchantOrderId(merchantOrderId: string) {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
where: { id: intent.bookingId },
|
||||
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');
|
||||
@@ -170,6 +179,36 @@ export class TicketsService {
|
||||
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 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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user