Merge branch 'dev' into feat/payment-microservice

This commit is contained in:
Abubeker Yasin
2026-06-16 15:24:33 +03:00
86 changed files with 10812 additions and 2009 deletions

View File

@@ -19,7 +19,6 @@
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
"dependencies": {
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
@@ -29,6 +28,7 @@
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^11.1.24",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
@@ -47,7 +47,8 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0"
"tsconfig-paths": "^4.2.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
@@ -62,6 +63,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.0",
"jest": "^29.7.0",
"prisma": "^6.19.3",
"supertest": "^7.0.0",

View File

@@ -0,0 +1,36 @@
-- Add sequence column to Station table if it doesn't exist
ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
-- Add index on sequence for Station
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence");
-- Add sequence column to Coach table if it doesn't exist
ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
-- Add index on sequence for Coach
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence");
-- Add missing columns to SeatClass if they don't exist
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0;
-- Add missing columns to User if they don't exist
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255);
-- Ensure Ticket has all required columns
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
-- Add missing columns to Booking if they don't exist
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY';
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255);
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER;
-- Ensure all indexes exist
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status");

View File

@@ -0,0 +1,164 @@
-- Add CASCADE delete to all foreign key constraints that are missing it
-- TrainSchedule relations
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
-- Coach relation
ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE;
-- CoachAssignment relations
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE;
-- Booking relations
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- BookingSeat relations
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
-- PaymentIntent
ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- PaymentRefund
ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE;
-- Ticket
ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- TicketSeat
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
-- WalletLedgerEntry
ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE;
-- Notification
ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
-- MenuItem
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE;
-- FoodOrder
ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- FoodOrderItem
ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE;
-- FaqArticle
ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE;
-- SupportMessage
ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE;
-- TripStopTime
ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- TripLiveStatus
ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- JourneySegment
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- AgentBooking
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- AgentShift
ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
-- AgentCommission
ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
-- BookingModification
ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- BookingCancellation
ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- GateValidationLog
ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE;
-- BaggageBooking
ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- RouteFareRule
ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
-- SegmentFareRule
ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
-- StationCrowdSignal
ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
-- SeatBlock
ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
-- SavedRoute
ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
-- LoyaltyLedgerEntry
ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
-- LoyaltyReward
ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
-- FareRule
ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;

View File

@@ -84,19 +84,20 @@ model CoachType {
}
model SeatClass {
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int @default(0) // per-km rate
premiumMinor Int @default(0) // flat fee per passenger
insuranceFeeMinor Int @default(0) // flat fee per passenger
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@schema("passenger")
@@ -226,22 +227,24 @@ enum DevicePlatform {
}
model User {
id String @id @default(uuid())
email String @unique
phone String @unique
fullName String
passwordHash String
role UserRole @default(PASSENGER)
nationality String?
nationalityCode String?
passportNumber String?
nationalId String?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
blockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
phone String @unique
fullName String
passwordHash String
role UserRole @default(PASSENGER)
nationality String?
nationalityCode String?
gender String? // Male, Female, Other
dateOfBirth DateTime?
passportNumber String?
nationalId String?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
blockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
faydaVerified Boolean @default(false)
faydaVerifiedAt DateTime?
@@ -307,21 +310,22 @@ model TravelerProfile {
}
model Station {
id String @id @default(uuid())
code String @unique
name String
city String
countryCode String?
isOperational Boolean @default(true)
timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
id String @id @default(uuid())
code String @unique
name String
city String
countryCode String?
sequence Int @default(0)
isOperational Boolean @default(true)
timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
@@index([city, countryCode])
@@index([sequence])
@@schema("passenger")
}
@@ -402,19 +406,20 @@ model TripLiveStatus {
}
model Coach {
id String @id @default(uuid())
coachTypeId String
number String @unique
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
capacity Int @default(0) // Total seats/beds
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
coachTypeId String
number String @unique
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
capacity Int @default(0) // Total seats/beds
sequence Int @default(0)
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[]
assignments CoachAssignment[]
@@index([coachTypeId])
@@index([sequence])
@@schema("passenger")
}
@@ -628,20 +633,21 @@ model PaymentRefund {
}
model Ticket {
id String @id @default(uuid())
bookingId String @unique
bookingRef String
status String @default("CONFIRMED")
qrPayload String
barcodePayload String?
pdfUrl String?
deliveryChannel String @default("EMAIL")
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
id String @id @default(uuid())
bookingId String @unique
bookingRef String
status String @default("ACTIVE")
qrPayload String
barcodePayload String?
pdfUrl String?
deliveryChannel String @default("EMAIL")
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
boardedAt DateTime?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
@@schema("passenger")
}

View File

@@ -4,7 +4,6 @@ import { randomUUID as uuidv4 } from 'crypto';
const prisma = new PrismaClient();
const EDR_ROUTE_ID = uuidv4();
const TRAIN_ID = uuidv4();
async function seedSystemUsers() {
@@ -24,6 +23,10 @@ async function seedSystemUsers() {
phone: '+251900000000',
passwordHash: adminHash,
role: 'ADMIN',
gender: 'Male',
dateOfBirth: new Date('1980-05-20'),
nationality: 'Ethiopian',
nationalId: 'ET123456789',
},
});
console.log(' ✅ Admin: admin@edr-platform.com / admin123');
@@ -39,6 +42,9 @@ async function seedSystemUsers() {
role: 'PASSENGER',
nationality: 'Ethiopian',
faydaVerified: true,
gender: 'Male',
dateOfBirth: new Date('1990-03-15'),
nationalId: 'ET987654321',
},
});
@@ -49,7 +55,7 @@ async function seedSystemUsers() {
data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' },
});
await prisma.walletAccount.create({
data: { passengerId: passengerRecord.id, balanceMinor: 50000 },
data: { passengerId: passengerRecord.id, balanceMinor: 500 },
});
}
await prisma.userPreferences.upsert({
@@ -68,6 +74,8 @@ async function seedSystemUsers() {
phone: '+251911111111',
passwordHash: agentHash,
role: 'AGENT',
gender: 'Female',
dateOfBirth: new Date('1992-07-22'),
},
});
await prisma.agent.upsert({
@@ -86,6 +94,8 @@ async function seedSystemUsers() {
phone: '+251922222222',
passwordHash: supervisorHash,
role: 'SUPERVISOR',
gender: 'Male',
dateOfBirth: new Date('1985-11-10'),
},
});
console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123');
@@ -99,6 +109,8 @@ async function seedSystemUsers() {
phone: '+251933333333',
passwordHash: staffHash,
role: 'STAFF',
gender: 'Female',
dateOfBirth: new Date('1995-09-08'),
},
});
console.log(' ✅ Staff: staff@edr-platform.com / staff123');
@@ -107,21 +119,21 @@ async function seedSystemUsers() {
async function seedStations() {
console.log('\n📍 Seeding 15 stations (Ethio-Djibouti Railway)...');
const stations = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 },
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 },
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 },
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 },
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 },
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 },
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 },
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 },
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 },
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 },
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 },
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 },
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 },
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150, sequence: 1 },
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320, sequence: 2 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240, sequence: 3 },
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130, sequence: 4 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780, sequence: 5 },
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920, sequence: 6 },
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450, sequence: 7 },
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670, sequence: 8 },
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578, sequence: 9 },
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150, sequence: 10 },
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670, sequence: 11 },
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340, sequence: 12 },
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560, sequence: 13 },
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450, sequence: 14 },
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200, sequence: 15 },
];
for (const station of stations) {
@@ -142,8 +154,8 @@ async function seedCoachTypesAndClasses() {
console.log('\n🚂 Seeding coach types and seat classes...');
const coachTypes = [
{ code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
{ code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' },
{ code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' },
{ code: 'HBC', name: 'Hard Berth Coach', type: 'Economy Bed' },
{ code: 'SBC', name: 'Soft Berth Coach', type: 'VIP Bed' },
];
for (const ct of coachTypes) {
@@ -155,12 +167,12 @@ async function seedCoachTypesAndClasses() {
}
const seatClasses = [
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 },
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 },
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 },
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 },
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 },
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 },
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
];
for (const sc of seatClasses) {
@@ -168,7 +180,7 @@ async function seedCoachTypesAndClasses() {
await prisma.seatClass.upsert({
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
update: {},
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor },
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
});
}
console.log(`${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
@@ -176,14 +188,12 @@ async function seedCoachTypesAndClasses() {
async function seedRoute() {
console.log('\n🛣 Seeding route and stops...');
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
const route = await prisma.route.upsert({
where: { code: 'EDR-101' },
where: { code: 'Route-101' },
update: {},
create: {
code: 'EDR-101',
code: 'Route-101',
name: 'Sebeta - Dire Dawa',
description: 'Outbound local route from Sebeta to Dire Dawa',
effectiveFrom: new Date('2026-01-01'),
@@ -193,15 +203,40 @@ async function seedRoute() {
});
const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE'];
const routeDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0];
for (let i = 0; i < stationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } },
update: {},
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 },
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
});
}
console.log(` ✅ Route with ${stationCodes.length} stops created`);
const returnRoute = await prisma.route.upsert({
where: { code: 'Route-102' },
update: {},
create: {
code: 'Route-102',
name: 'Dire Dawa - Sebeta',
description: 'Inbound local route from Dire Dawa to Sebeta',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
const returnStationCodes = ['DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
const returnRouteDistancesKm = [0, 119.4, 181.4, 232.8, 306.3, 323.1, 345.8, 401.5, 413.0];
for (let i = 0; i < returnStationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: returnStationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: returnRoute!.id, sequence: i + 1 } },
update: {},
create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] },
});
}
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
}
async function seedCoaches() {
@@ -211,9 +246,9 @@ async function seedCoaches() {
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
const coaches = [
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 },
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 },
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 },
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 128, sequence: 1 },
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66, sequence: 2 },
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 40, sequence: 3 },
];
let totalSeats = 0;
@@ -230,19 +265,23 @@ async function seedCoaches() {
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
let seatIndex = 1;
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
for (const col of ['A', 'B', 'C', 'D']) {
for (const col of ['A', 'B', 'C', 'D', 'E']) {
if (seatIndex > coach.capacity) break;
let bedPosition: string | null = null;
if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
if (c.coachTypeId === ecoBedCoachType!.id) {
// 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 (c.coachTypeId === vipBedCoachType!.id) {
// VIP Bed: 2-row cycle (upper, lower)
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
}
const seatData = {
seatNumber: seatIndex.toString(),
isWindow: col === 'A' || col === 'D',
isAisle: col === 'B' || col === 'C',
isWindow: col === 'A' || col === 'E',
isAisle: col === 'B' || col === 'C' || col === 'D',
bedPosition,
};
@@ -264,67 +303,102 @@ async function seedTrips() {
const train = await prisma.train.upsert({
where: { number: 'EDR-001' },
update: {},
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' },
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Express Service' },
});
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
const firstReturnStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
const lastReturnStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
const coaches = await prisma.coach.findMany();
const now = new Date();
const schedules = [];
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
for (let d = 0; d < 30; d++) {
const schedules = [];
for (let d = 0; d < 5; d++) {
const tripDate = new Date(now);
tripDate.setDate(tripDate.getDate() + d);
tripDate.setHours(8, 0, 0, 0);
const departureAt = new Date(tripDate);
const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000);
tripDate.setHours(20, 30, 0, 0);
schedules.push({
trainId: train.id,
routeId: route!.id,
originStationId: firstStation!.id,
destinationStationId: lastStation!.id,
departureAt,
arrivalAt,
durationMinutes: 4 * 24 * 60,
stopsCount: 15,
departureAt: new Date(tripDate),
arrivalAt: new Date(tripDate), // patched below
durationMinutes: 0, // patched below
stopsCount: 9,
});
}
const createdSchedules = await Promise.all(
schedules.map(s => prisma.trainSchedule.create({ data: s }))
);
// Create TripStopTimes for each schedule
const routeStops = await prisma.routeStop.findMany({
where: { routeId: route!.id },
orderBy: { sequence: 'asc' },
include: { route: true },
for (let d = 0; d < 5; d++) {
const returnTripDate = new Date(tomorrow);
returnTripDate.setDate(returnTripDate.getDate() + d);
returnTripDate.setHours(20, 0, 0, 0);
schedules.push({
trainId: train.id,
routeId: returnRoute!.id,
originStationId: firstReturnStation!.id,
destinationStationId: lastReturnStation!.id,
departureAt: new Date(returnTripDate),
arrivalAt: new Date(returnTripDate), // patched below
durationMinutes: 0, // patched below
stopsCount: 9,
});
}
// Load route stops for both routes upfront
const routeStopsMap = new Map<string, { stationId: string; sequence: number; distanceKm: number }[]>();
for (const r of [route!, returnRoute!]) {
const stops = await prisma.routeStop.findMany({
where: { routeId: r.id },
orderBy: { sequence: 'asc' },
});
routeStopsMap.set(r.id, stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm! })));
}
// Compute duration from total route distance at 60 km/h
function routeDuration(stops: { distanceKm: number }[]): number {
const totalKm = stops[stops.length - 1].distanceKm - stops[0].distanceKm;
return Math.ceil(totalKm / 60 * 60);
}
// Patch arrivalAt and durationMinutes using distance-based timing
const patchedSchedules = schedules.map(s => {
const stops = routeStopsMap.get(s.routeId!)!;
const durationMinutes = routeDuration(stops);
return { ...s, durationMinutes, arrivalAt: new Date(s.departureAt.getTime() + durationMinutes * 60_000) };
});
const createdSchedules = await Promise.all(
patchedSchedules.map(s => prisma.trainSchedule.create({ data: s }))
);
// Create TripStopTimes using cumulative distanceKm at 60 km/h
for (const schedule of createdSchedules) {
const stopTimes = [];
for (const routeStop of routeStops) {
const minutesFromStart = (routeStop.sequence - 1) * 480; // 8 hours per stop
const stops = routeStopsMap.get(schedule.routeId!)!;
const originKm = stops[0].distanceKm;
const stopTimes = stops.map(stop => {
const minutesFromStart = Math.ceil((stop.distanceKm - originKm) / 60 * 60);
const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000);
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() + 30 * 60_000); // 30 min stop
stopTimes.push({
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() - 5 * 60_000); // 5 min dwell
return {
scheduleId: schedule.id,
stationId: routeStop.stationId,
sequence: routeStop.sequence,
stationId: stop.stationId,
sequence: stop.sequence,
plannedArrivalAt,
plannedDepartureAt,
});
}
await Promise.all(
stopTimes.map(st => prisma.tripStopTime.create({ data: st }))
);
};
});
// First stop: arrival = departure (no dwell at origin)
stopTimes[0].plannedArrivalAt = stopTimes[0].plannedDepartureAt;
await Promise.all(stopTimes.map(st => prisma.tripStopTime.create({ data: st })));
}
const coachAssignments = [];
@@ -355,7 +429,7 @@ async function seedTrips() {
async function seedFareRules() {
console.log('\n💰 Seeding fare rules...');
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
const seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01');
@@ -374,7 +448,7 @@ async function seedFareRules() {
seatClassId: sc.id,
passengerCategory: 'CHILD' as const,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
discountPercent: 50,
discountPercent: 10,
currency: 'ETB',
validFrom,
});
@@ -437,13 +511,50 @@ async function seedPaymentMethods() {
console.log(`${methods.length} payment methods created`);
}
async function seedSegmentFares() {
console.log('\n📍 Seeding segment fare rules...');
const route = await prisma.route.findUnique({
where: { code: 'Route-101' },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
const seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01');
if (route && route.stops.length > 2) {
for (const sc of seatClasses) {
await prisma.segmentFareRule.create({
data: {
routeId: route.id,
seatClassId: sc.id,
originStopSequence: 1,
destinationStopSequence: 3,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
validFrom,
},
}).catch(() => {});
await prisma.segmentFareRule.create({
data: {
routeId: route.id,
seatClassId: sc.id,
originStopSequence: 5,
destinationStopSequence: 9,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
validFrom,
},
}).catch(() => {});
}
console.log(`${seatClasses.length * 2} segment fare rules created`);
}
}
async function seedNotificationTemplates() {
console.log('\n🔔 Seeding notification templates...');
const templates = [
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' },
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' },
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' },
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
];
@@ -477,13 +588,13 @@ async function seedMenuAndFood() {
const sandwichId = uuidv4();
await prisma.menuItem.create({
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 },
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
}).catch(() => {}); // ignore if exists
await prisma.menuItem.create({
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 },
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
}).catch(() => {}); // ignore if exists
await prisma.menuItem.create({
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 },
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
}).catch(() => {}); // ignore if exists
}
console.log(` ✅ Menu categories and items created`);
@@ -494,7 +605,7 @@ async function seedPromotions() {
const promos = [
{ id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
{ id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) },
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 100, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
];
for (const p of promos) {
@@ -584,6 +695,7 @@ async function main() {
['promotions', seedPromotions],
['FAQ', seedFAQ],
['fraud rules', seedFraudRules],
['segment fares', seedSegmentFares],
];
let failed = 0;

View File

@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { AuditModule } from './common/audit.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
@@ -39,6 +40,8 @@ import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { AuditModuleFeature } from './modules/audit/audit.module';
import { CurrenciesModule } from './modules/currencies/currencies.module';
@Module({
imports: [
@@ -59,6 +62,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
AuditModule,
I18nModule,
IamModule,
AuthModule,
@@ -85,6 +89,8 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
SeatClassesModule,
FareEngineModule,
VerifaydaModule,
AuditModuleFeature,
CurrenciesModule,
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
import { AuditService } from './audit.service';
@Module({
imports: [PrismaModule],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,92 @@
import { Injectable, Inject, Optional } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaService } from './prisma.service';
@Injectable()
export class AuditService {
constructor(
private prisma: PrismaService,
@Optional() @Inject(REQUEST) private request?: any,
) {}
async log(input: {
userId?: string;
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
entityType: string;
entityId?: string;
oldData?: any;
newData?: any;
}) {
try {
const ipAddress = this.getIpAddress();
const userAgent = this.getUserAgent();
await this.prisma.auditLog.create({
data: {
userId: input.userId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
oldData: input.oldData,
newData: input.newData,
ipAddress,
userAgent,
},
});
} catch (error) {
console.error('Failed to log audit event:', error);
// Don't throw - audit logging should not break main operations
}
}
private getIpAddress(): string {
if (!this.request) return '';
return (
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
this.request.headers['x-real-ip'] ||
this.request.connection?.remoteAddress ||
this.request.socket?.remoteAddress ||
this.request.ip ||
''
);
}
private getUserAgent(): string {
return this.request?.headers?.['user-agent'] || '';
}
async getLogs(filters: any = {}) {
const where: any = {};
if (filters.search) {
where.OR = [
{ entityId: { contains: filters.search, mode: 'insensitive' } },
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.action) {
where.action = filters.action;
}
if (filters.entityType) {
where.entityType = filters.entityType;
}
return this.prisma.auditLog.findMany({
where,
include: { user: true },
orderBy: { createdAt: 'desc' },
take: 500, // Limit to last 500 logs
});
}
async getLog(id: string) {
return this.prisma.auditLog.findUnique({
where: { id },
include: { user: true },
});
}
}

View File

@@ -6,7 +6,7 @@ import { registerAs } from '@nestjs/config';
* never interfere. Points at the dedicated `payment` vhost on the shared broker.
*/
export default registerAs('rabbitmq', () => ({
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
url: process.env.PAYMENT_RABBITMQ_URL,
/** Max unacked payment events held by this consumer at once. */
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
}));

View File

@@ -34,6 +34,14 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 🆕 Latest Updates
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
- **Multi-Currency Display:** Bookings track display currency and converted amounts
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
## Key Features
### 🎫 Booking Lifecycle
@@ -47,6 +55,11 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
- Round-trip booking with return journey scheduling
- Coach type selection with seat class and pricing options
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
- **NEW:** Display currency and converted pricing per booking
### 👤 Passenger Verification
1. **Ethiopian Nationals:**
@@ -65,12 +78,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
- **NEW:** Premium charges and insurance fees per seat class
- **NEW:** Transparent fee breakdown in pricing calculations
### 💳 Payment Integration
1. **Ethiopian Payment Methods:**
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
- **eBirr** - Electronic payment gateway
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
@@ -84,8 +98,9 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
- **NEW:** Sequence-based coach ordering for consistent display
### 🎟️ Ticketing
- QR code and barcode generation
@@ -93,6 +108,8 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
- **NEW:** Complete audit trail for compliance and reporting
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
@@ -118,16 +135,50 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Failed payment pattern detection
- Automatic user blocking
### 👤 Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
- **NEW:** Complete demographic data for personalized services
- **NEW:** Improved user targeting and communications
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- Commission tracking
- Cash reconciliation
### 🚌 Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
- Transit stop optimization and route planning
### 🔄 Round-Trip Booking
- One-way and round-trip journey options
- Flexible return date selection
- Combined pricing for outbound + return legs
- Separate seat management per leg
- Independent modification/cancellation per leg
- Return journey tracking and notifications
- **NEW:** Booking type stored for analytics and reporting
### 🚐 Coach Type & Class Selection
- Browse available coach types per route (standard coaches, premium coaches)
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
- Deferred pricing at seat selection stage
- Coach amenities and features display
- **NEW:** Sequence-based coach ordering for consistent UI
- **NEW:** Premium and insurance fee transparency per class
### 📊 Data Organization
- **Stations:** Ordered by sequence (1-15) for consistent route display
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
- **Booking History:** Sorted chronologically with filtering options
## Authentication
@@ -197,7 +248,6 @@ List endpoints support pagination:
Payment providers send notifications to:
- \`POST /payments/webhooks/telebirr\` (Ethiopia)
- \`POST /payments/webhooks/cbe-birr\` (Ethiopia)
- \`POST /payments/webhooks/ebirr\` (Ethiopia)
- \`POST /payments/webhooks/waafi\` (Djibouti)
- \`POST /payments/webhooks/card\` (International)
@@ -212,33 +262,38 @@ Payment providers send notifications to:
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, and commission tracking")
.addTag("Auth", "User registration, login, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
.addTag("Dashboard", "Aggregated dashboard data for home screen")
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
.addTag("Fleet", "Train services, coaches, and seat configurations")
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
.addTag("Passengers", "Passenger registration, verification, and profiles")
.addTag("Payment", "Payment processing, intents, and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers")
.addTag("Promotions", "Promo codes, campaigns, and discount management")
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
.addTag("Routes", "Route templates with stops and fare rules")
.addTag("Schedule", "Trip schedules, availability, and status updates")
.addTag("Search", "Trip search, availability checks, and fare quotes")
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
.addTag("Seats", "Seat maps, holds, releases, and blocking")
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
.addTag("Stations", "Station directory and information")
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
.addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency")
.addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API")
.addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations")
.addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking")
.addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management")
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management")
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles")
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed class configuration and pricing")
.addTag("Seats", "Seat maps, holds (15-min expiry), releases, blocking, and inventory")
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View 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);
}
}

View 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 {}

View File

@@ -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;

View File

@@ -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]

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -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],

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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 } });
}

View File

@@ -0,0 +1,8 @@
export class SendEmail {
to: string;
subject: string;
body: string;
html?: string;
templateKey?: string;
context?: Record<string, unknown>;
}

View File

@@ -0,0 +1,9 @@
export class SendMessage {
to: string;
message: string;
from?: string;
}
export class BulkMessagesDto {
messages: SendMessage[];
}

View File

@@ -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 {};
}
}

View File

@@ -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')

View File

@@ -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 {}

View File

@@ -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;

View File

@@ -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 {};
}
}

View File

@@ -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) {

View File

@@ -1,60 +1,23 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentService,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@Module({
imports: [
SeatsModule,
TicketsModule,
HttpModule.register({ timeout: 10_000 }),
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>("rabbitmq.url") as string,
exchanges: [
{
name: PAYMENT_EVENTS_EXCHANGE,
type: "topic",
options: { durable: true },
},
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
queues: [
{
name: PASSENGER_QUEUE.dlq,
exchange: PAYMENT_EVENTS_DLX,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER),
options: { durable: true },
},
],
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
connectionInitOptions: { wait: false },
}),
}),
],
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
})

View File

@@ -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 } },
},
});

View File

@@ -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' })

View File

@@ -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;
}

View File

@@ -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[] }> {

View File

@@ -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);
}

View File

@@ -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[];
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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')

View File

@@ -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
};
}