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