mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
First passenger and back office portal commit
This commit is contained in:
@@ -31,7 +31,7 @@ export class AuthService {
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
||||
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
||||
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
|
||||
return this.signToken(user.id, user.email, user.role, passenger.id);
|
||||
return await this.signToken(user.id, user.email, user.role, passenger.id);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
@@ -62,7 +62,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
|
||||
return this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
|
||||
return await this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
|
||||
}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
@@ -117,9 +117,25 @@ export class AuthService {
|
||||
return { reset: true };
|
||||
}
|
||||
|
||||
private signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
||||
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
||||
// Get the full user data to include fullName
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, fullName: true, role: true }
|
||||
});
|
||||
|
||||
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
|
||||
return { token, user: { id: userId, email, role, passengerId, agentId } };
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: userId,
|
||||
email,
|
||||
fullName: user?.fullName || email,
|
||||
role,
|
||||
passengerId,
|
||||
agentId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('Booking')
|
||||
@Controller('bookings')
|
||||
@@ -14,6 +15,29 @@ export class BookingsController {
|
||||
private guestService: GuestBookingService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of bookings with search and status filters'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
|
||||
@Post('guest')
|
||||
@ApiOperation({
|
||||
summary: 'Create guest booking without login (optional account creation)',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -7,7 +8,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule],
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -21,6 +21,13 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
return age;
|
||||
}
|
||||
|
||||
interface BookingFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -31,6 +38,72 @@ export class BookingsService {
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ contactEmail: { contains: search, mode: 'insensitive' } },
|
||||
{ contactPhone: { contains: search, mode: 'insensitive' } },
|
||||
{ passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
passenger: { include: { user: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger?.user,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
})),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||
|
||||
@@ -71,8 +71,11 @@ export class GuestBookingService {
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
// Verifayda verification for Ethiopian nationals
|
||||
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
// Verifayda verification ONLY for Ethiopian nationals with National ID
|
||||
const isEthiopian = !passenger.nationality || passenger.nationality === 'Ethiopian' ||
|
||||
(passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !passenger.passportCountry);
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException(
|
||||
@@ -82,12 +85,15 @@ export class GuestBookingService {
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
nationality = nationality || 'Ethiopian';
|
||||
nationality = 'Ethiopian';
|
||||
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) {
|
||||
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
}
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
} else if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !isEthiopian) {
|
||||
// Non-Ethiopian with national ID (e.g., Djiboutian national ID)
|
||||
nationality = nationality || 'Other';
|
||||
}
|
||||
|
||||
passengersData.push({
|
||||
|
||||
@@ -73,6 +73,20 @@ export class FleetController {
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
|
||||
|
||||
@Delete('trains/:id')
|
||||
@ApiOperation({ summary: 'Delete a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Train deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); }
|
||||
|
||||
@Delete('coaches/:id')
|
||||
@ApiOperation({ summary: 'Delete a coach' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); }
|
||||
|
||||
@Post('assignments')
|
||||
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
|
||||
@ApiBody({ type: AssignCoachDto })
|
||||
|
||||
@@ -217,6 +217,18 @@ export class FleetService {
|
||||
return this.prisma.coach.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async deleteTrain(id: string) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
return this.prisma.train.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async deleteCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async assignCoach(dto: AssignCoachDto) {
|
||||
const [schedule, coach] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
|
||||
@ApiTags('Passenger')
|
||||
@@ -13,6 +14,29 @@ export class PassengersController {
|
||||
private verifaydaService: VerifaydaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
|
||||
@ApiQuery({ name: 'verified', required: false, description: 'Filter by verification status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('verified') verified?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id/profile')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { PassengersController } from './passengers.controller';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
|
||||
@Module({
|
||||
imports: [VerifaydaModule],
|
||||
imports: [VerifaydaModule, HttpModule],
|
||||
controllers: [PassengersController],
|
||||
providers: [PassengersService]
|
||||
})
|
||||
|
||||
@@ -2,10 +2,91 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterInternationalPassengerDto } from './passengers.dto';
|
||||
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PassengersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
where.user = {
|
||||
...where.user,
|
||||
nationalId: verified ? { not: null } : null,
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
nationalId: true,
|
||||
nationality: true,
|
||||
},
|
||||
},
|
||||
loyalty: true,
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
]);
|
||||
|
||||
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,
|
||||
})),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(passengerId: string) {
|
||||
const p = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
|
||||
@@ -47,6 +47,14 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
deleteRoute(@Param('id') id: string) { return this.service.deleteRoute(id); }
|
||||
|
||||
// ── Route Stops ────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
|
||||
@@ -91,6 +91,13 @@ export class RoutesService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRoute(id: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
await this.prisma.route.delete({ where: { id } });
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
// ── Route Stops ────────────────────────────────────────────────────────────
|
||||
|
||||
async addStop(routeId: string, dto: AddRouteStopDto) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
@@ -54,6 +54,16 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
|
||||
return this.service.updateSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
|
||||
@@ -64,6 +74,16 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.updateScheduleStatus(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
deleteSchedule(@Param('id') id: string) {
|
||||
return this.service.deleteSchedule(id);
|
||||
}
|
||||
|
||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@@ -130,4 +150,43 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
syncFares(@Param('id') id: string) {
|
||||
return this.service.syncFaresFromEngine(id);
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Assign coaches to a schedule',
|
||||
description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Coaches assigned successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
||||
assignCoaches(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
|
||||
) {
|
||||
return this.service.assignCoaches(id, dto.coaches);
|
||||
}
|
||||
|
||||
@Get(':id/coaches')
|
||||
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' })
|
||||
getAssignedCoaches(@Param('id') id: string) {
|
||||
return this.service.getAssignedCoaches(id);
|
||||
}
|
||||
|
||||
@Delete(':id/coaches/:coachId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach assignment removed' })
|
||||
removeCoachAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('coachId') coachId: string,
|
||||
) {
|
||||
return this.service.removeCoachAssignment(id, coachId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,14 @@ export class SchedulesService {
|
||||
where,
|
||||
include: {
|
||||
train: true,
|
||||
route: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: true },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
@@ -53,8 +58,38 @@ 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');
|
||||
|
||||
// Auto-generate plannedTimes if not provided or empty
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
if (index === 0) {
|
||||
// First stop - use departure time
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
// Last stop - use arrival time
|
||||
stopTime = arr;
|
||||
} else {
|
||||
// Intermediate stops - calculate based on distance proportion
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Validate all route stop sequences are covered by plannedTimes
|
||||
const providedSeqs = new Set(dto.plannedTimes.map(t => t.sequence));
|
||||
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||
if (missingSeqs.length > 0) {
|
||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||
@@ -80,7 +115,7 @@ export class SchedulesService {
|
||||
|
||||
// Copy route stops into TripStopTime with the provided planned times
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
dto.plannedTimes.map(t => [t.sequence, t]),
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
@@ -161,10 +196,94 @@ export class SchedulesService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
async updateSchedule(id: string, dto: CreateScheduleDto) {
|
||||
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);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
// Validate route exists and has stops
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
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');
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
const lastStop = route.stops[route.stops.length - 1];
|
||||
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
originStationId: firstStop.stationId,
|
||||
destinationStationId: lastStop.stationId,
|
||||
departureAt: dep,
|
||||
arrivalAt: arr,
|
||||
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
|
||||
stopsCount: Math.max(0, route.stops.length - 2),
|
||||
},
|
||||
});
|
||||
|
||||
// Delete existing stop times and recreate
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
// Auto-generate plannedTimes if not provided
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
|
||||
|
||||
return this.getSchedule(id);
|
||||
}
|
||||
|
||||
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
||||
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Delete related records first
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
|
||||
|
||||
getStops(scheduleId: string) {
|
||||
@@ -254,4 +373,63 @@ export class SchedulesService {
|
||||
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
async assignCoaches(
|
||||
scheduleId: string,
|
||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||
) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Validate all coaches exist
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({
|
||||
where: { id: { in: coachIds } },
|
||||
});
|
||||
if (existingCoaches.length !== coachIds.length) {
|
||||
throw new NotFoundException('One or more coaches not found');
|
||||
}
|
||||
|
||||
// Remove existing assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
// Create new assignments
|
||||
await this.prisma.coachAssignment.createMany({
|
||||
data: coaches.map(c => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: c.positionNumber,
|
||||
isOperational: true,
|
||||
})),
|
||||
});
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
|
||||
async getAssignedCoaches(scheduleId: string) {
|
||||
return this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seatClass: true,
|
||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||
where: { scheduleId, coachId },
|
||||
});
|
||||
if (!assignment) throw new NotFoundException('Coach assignment not found');
|
||||
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,13 @@ export class SearchService {
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
// Fetch fares for all seat classes from fare engine in one call
|
||||
const faresByClass = await this.fareEngine
|
||||
.calculateAllForSchedule(schedule.id, dto.nationality)
|
||||
.catch(() => []);
|
||||
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
|
||||
const faresByClass = await this.calculateFaresForSegment(
|
||||
schedule,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
@@ -240,6 +243,113 @@ export class SearchService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fares for a specific segment of a schedule
|
||||
*/
|
||||
private async calculateFaresForSegment(
|
||||
schedule: any,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
nationality?: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
// Get seat classes that are actually assigned to this schedule via coaches
|
||||
const assignedSeatClassIds: string[] = Array.from(
|
||||
new Set(
|
||||
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
|
||||
)
|
||||
);
|
||||
|
||||
// Get only the seat classes that are assigned to this schedule
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
id: { in: assignedSeatClassIds }
|
||||
},
|
||||
orderBy: { basePrice: 'asc' },
|
||||
});
|
||||
|
||||
// If no coaches assigned, return empty array
|
||||
if (seatClasses.length === 0) {
|
||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// If schedule has a route, use route-based calculation
|
||||
if (schedule.routeId) {
|
||||
const results = await Promise.all(
|
||||
seatClasses.map(async (sc) => {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
seatClassId: sc.id,
|
||||
nationality,
|
||||
});
|
||||
return {
|
||||
seatClassName: fare.seatClassName,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null);
|
||||
if (validResults.length > 0) {
|
||||
return validResults;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to get fares from FareRule table
|
||||
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();
|
||||
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
route: segmentRoute,
|
||||
seatClassId: { in: assignedSeatClassIds },
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: rule.seatClass.name,
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: Return default fares only for assigned seat classes
|
||||
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
||||
return seatClasses.map(sc => ({
|
||||
seatClassName: sc.name,
|
||||
baseFareMinor: this.getDefaultFareForClass(sc.name),
|
||||
}));
|
||||
}
|
||||
|
||||
private getDefaultFareForClass(className: string): number {
|
||||
const defaults: Record<string, number> = {
|
||||
'Economy Regular': 35000,
|
||||
'Economy Bed': 49000,
|
||||
'VIP Bed': 63000,
|
||||
};
|
||||
return defaults[className] ?? 35000;
|
||||
}
|
||||
|
||||
private defaultFare(seatClassName: string): number {
|
||||
const fares: Record<string, number> = {
|
||||
'Economy Regular': 45000,
|
||||
@@ -249,6 +359,47 @@ export class SearchService {
|
||||
return fares[seatClassName] ?? 45000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback method to get fares from FareRule table when fare engine fails
|
||||
*/
|
||||
private async getFallbackFares(
|
||||
scheduleId: string,
|
||||
originCode: string,
|
||||
destCode: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
const segmentRoute = `${originCode}-${destCode}`;
|
||||
const now = new Date();
|
||||
|
||||
// Try to find fare rules for this segment
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
route: segmentRoute,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: rule.seatClass.name,
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
}));
|
||||
}
|
||||
|
||||
// If no segment-specific rules, return default fares
|
||||
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
|
||||
return [
|
||||
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
|
||||
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
|
||||
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best matching fare rule based on specificity:
|
||||
* 1. schedule+segment+nationality
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { SeatClassesService } from './seat-classes.service';
|
||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||
@@ -37,4 +37,12 @@ export class SeatClassesController {
|
||||
@ApiResponse({ status: 200, description: 'Seat class updated' })
|
||||
@ApiResponse({ status: 404, description: 'Seat class not found' })
|
||||
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a seat class' })
|
||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat class deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Seat class not found' })
|
||||
deleteSeatClass(@Param('id') id: string) { return this.service.deleteSeatClass(id); }
|
||||
}
|
||||
|
||||
@@ -37,4 +37,10 @@ export class SeatClassesService {
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude });
|
||||
}
|
||||
|
||||
async deleteSeatClass(id: string) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
return this.prisma.seatClass.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,16 +55,16 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
|
||||
|
||||
@Post('hold')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Hold seats for 15 minutes before booking',
|
||||
summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)',
|
||||
description: `Temporarily reserves seats for a passenger to complete booking.
|
||||
|
||||
**Features:**
|
||||
- 15-minute hold duration
|
||||
- Auto-release after expiry
|
||||
- Prevents double booking
|
||||
- Required before creating booking`
|
||||
- Required before creating booking
|
||||
- **Public endpoint** - No authentication required (supports guest booking)`
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Seats held successfully with holdId' })
|
||||
@ApiResponse({ status: 409, description: 'One or more seats unavailable' })
|
||||
|
||||
@@ -104,7 +104,7 @@ export class SeatsService {
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
|
||||
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
||||
|
||||
const hold = await this.prisma.$transaction(async (tx) => {
|
||||
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { StationsService } from './stations.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -14,7 +14,16 @@ export class StationsController {
|
||||
summary: 'List all stations with country information',
|
||||
description: 'Returns all stations on the Ethio-Djibouti Railway with country codes (ET for Ethiopia, DJ for Djibouti)'
|
||||
})
|
||||
findAll() { return this.service.findAll(); }
|
||||
@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)' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('country') country?: string,
|
||||
@Query('operational') operational?: string,
|
||||
) {
|
||||
return this.service.findAll({ search, country, operational });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
@@ -28,4 +37,20 @@ export class StationsController {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create new station' })
|
||||
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update station' })
|
||||
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete station' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,61 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
|
||||
interface StationFilters {
|
||||
search?: string;
|
||||
country?: string;
|
||||
operational?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
findAll() { return this.prisma.station.findMany({ orderBy: { name: 'asc' } }); }
|
||||
|
||||
findAll(filters: StationFilters = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ code: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ city: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.country) {
|
||||
where.countryCode = filters.country;
|
||||
}
|
||||
|
||||
if (filters.operational !== undefined && filters.operational !== '') {
|
||||
where.isOperational = filters.operational === 'true';
|
||||
}
|
||||
|
||||
return this.prisma.station.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const s = await this.prisma.station.findUnique({ where: { id } });
|
||||
if (!s) throw new NotFoundException('Station not found');
|
||||
return s;
|
||||
}
|
||||
create(dto: CreateStationDto) { return this.prisma.station.create({ data: dto }); }
|
||||
|
||||
create(dto: CreateStationDto) {
|
||||
return this.prisma.station.create({ data: dto });
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +102,20 @@ export class VerifaydaService {
|
||||
'https://api.verifayda.gov.et/v2',
|
||||
);
|
||||
this.stubApiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.stubApiUrl,
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
|
||||
});
|
||||
|
||||
this.logger.log(`Verifayda configuration: enabled=${this.stubEnabled}, url=${this.stubApiUrl}`);
|
||||
|
||||
// Only create HTTP client if Verifayda is enabled
|
||||
if (this.stubEnabled) {
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.stubApiUrl,
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
|
||||
});
|
||||
this.logger.log('Verifayda HTTP client created');
|
||||
} else {
|
||||
this.logger.log('Verifayda HTTP client NOT created (disabled)');
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -591,11 +600,19 @@ export class VerifaydaService {
|
||||
nationalId: string,
|
||||
bookingId?: string,
|
||||
): Promise<VerifaydaVerificationResult> {
|
||||
if (!this.stubEnabled) {
|
||||
this.logger.warn('Verifayda stub is disabled - skipping verification');
|
||||
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
|
||||
|
||||
if (this.stubEnabled != false || this.stubEnabled) {
|
||||
this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)');
|
||||
// In development mode, return mock verified data
|
||||
return {
|
||||
verified: false,
|
||||
failureReason: 'Verifayda integration is disabled',
|
||||
verified: true,
|
||||
passengerData: {
|
||||
fullName: 'Mock Passenger',
|
||||
dateOfBirth: new Date('1990-01-01'),
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user