Refactor business logic for train,schedule,coach,seat and search modules

This commit is contained in:
Roba Boru
2026-05-22 14:47:38 +03:00
parent 9151110fd8
commit 096c717bfa
48 changed files with 2254 additions and 2865 deletions

View File

@@ -1,52 +1,14 @@
-- CreateTable
CREATE TABLE "SeatClass" (
-- CreateTable SeatClass (runs before initial migration)
CREATE TABLE IF NOT EXISTS "SeatClass" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"basePrice" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW(),
CONSTRAINT "SeatClass_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name");
-- Seed default seat classes so existing coaches can be migrated
INSERT INTO "SeatClass" ("id", "name", "description", "basePrice", "isActive", "createdAt", "updatedAt")
VALUES
('sc_economy', 'Economy Seat', 'Standard economy seating', 45000, true, NOW(), NOW()),
('sc_business', 'Business Seat','Comfortable business class', 90000, true, NOW(), NOW()),
('sc_first', 'VIP Bed', 'First class VIP bed', 135000, true, NOW(), NOW());
-- Add seatClassId column to Coach (nullable first for migration safety)
ALTER TABLE "Coach" ADD COLUMN "seatClassId" TEXT;
-- Map existing serviceClass enum values to new SeatClass ids
UPDATE "Coach" SET "seatClassId" = 'sc_economy' WHERE "serviceClass" = 'ECONOMY';
UPDATE "Coach" SET "seatClassId" = 'sc_business' WHERE "serviceClass" = 'BUSINESS';
UPDATE "Coach" SET "seatClassId" = 'sc_first' WHERE "serviceClass" = 'FIRST';
-- Make seatClassId NOT NULL now that all rows are populated
ALTER TABLE "Coach" ALTER COLUMN "seatClassId" SET NOT NULL;
-- Drop old serviceClass column
ALTER TABLE "Coach" DROP COLUMN "serviceClass";
-- Add seatClassId to FareRule
ALTER TABLE "FareRule" ADD COLUMN "seatClassId" TEXT;
UPDATE "FareRule" SET "seatClassId" = 'sc_economy' WHERE "serviceClass" = 'ECONOMY';
UPDATE "FareRule" SET "seatClassId" = 'sc_business' WHERE "serviceClass" = 'BUSINESS';
UPDATE "FareRule" SET "seatClassId" = 'sc_first' WHERE "serviceClass" = 'FIRST';
ALTER TABLE "FareRule" ALTER COLUMN "seatClassId" SET NOT NULL;
ALTER TABLE "FareRule" DROP COLUMN "serviceClass";
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey"
FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey"
FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS "SeatClass_name_key" ON "SeatClass"("name");

View File

@@ -1 +1,2 @@
ALTER TABLE "SeatClass" ALTER COLUMN "updatedAt" SET DEFAULT NOW();
-- updatedAt default already set in initial migration, no-op
SELECT 1;

View File

@@ -3,8 +3,9 @@ generator client {
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
schemas = ["edr_passenger"]
}
enum UserRole {
@@ -37,15 +38,6 @@ enum SeatStatus {
BLOCKED
}
enum ServiceClass {
ECONOMY_REGULAR
ECONOMY_BED_LOWER
ECONOMY_BED_MIDDLE
ECONOMY_BED_UPPER
VIP_BED_LOWER
VIP_BED_UPPER
}
enum PassengerCategory {
ADULT
CHILD
@@ -74,6 +66,7 @@ model SeatClass {
updatedAt DateTime @updatedAt
coaches Coach[]
fareRules FareRule[]
routeFareRules RouteFareRule[]
}
enum BookingStatus {
@@ -240,41 +233,45 @@ model Station {
timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
originTrips Trip[] @relation("OriginTrips")
destinationTrips Trip[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
@@index([city, countryCode])
}
model TrainService {
id String @id @default(uuid())
number String @unique
name String
operatorId String @default("op_edr")
model Train {
id String @id @default(uuid())
number String @unique
name String
operatorId String @default("op_edr")
operatorName String?
trips Trip[]
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedules TrainSchedule[]
}
model Trip {
id String @id @default(uuid())
serviceId String
model TrainSchedule {
id String @id @default(uuid())
trainId String
routeId String?
originStationId String
destinationStationId String
departureAt DateTime
arrivalAt DateTime
durationMinutes Int
status TripStatus @default(SCHEDULED)
stopsCount Int @default(0)
reservedCount Int @default(0)
onTimePercent Int @default(100)
carbonRating String @default("A")
status TripStatus @default(SCHEDULED)
stopsCount Int @default(0)
reservedCount Int @default(0)
onTimePercent Int @default(100)
carbonRating String @default("A")
notes String?
service TrainService @relation(fields: [serviceId], references: [id])
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coaches Coach[]
train Train @relation(fields: [trainId], references: [id])
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
bookings Booking[]
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
@@ -284,62 +281,79 @@ model Trip {
}
model TripStopTime {
id String @id @default(uuid())
tripId String
id String @id @default(uuid())
scheduleId String
stationId String
sequence Int
plannedArrivalAt DateTime?
plannedDepartureAt DateTime?
actualArrivalAt DateTime?
status StopStatus @default(UPCOMING)
trip Trip @relation(fields: [tripId], references: [id])
station Station @relation(fields: [stationId], references: [id])
@@unique([tripId, sequence])
status StopStatus @default(UPCOMING)
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
station Station @relation(fields: [stationId], references: [id])
@@unique([scheduleId, sequence])
}
model TripLiveStatus {
id String @id @default(uuid())
tripId String @unique
id String @id @default(uuid())
scheduleId String @unique
state String
currentLocationLabel String?
progressPercent Int @default(0)
delayMinutes Int @default(0)
progressPercent Int @default(0)
delayMinutes Int @default(0)
currentSpeedKph Int?
platformLabel String?
updatedAt DateTime @updatedAt
trip Trip @relation(fields: [tripId], references: [id])
updatedAt DateTime @updatedAt
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
}
model Coach {
id String @id @default(uuid())
tripId String
label String
serviceClass ServiceClass
seatClassId String?
capacity Int?
sequence Int?
coachType String?
amenities Json?
trip Trip @relation(fields: [tripId], references: [id])
seatClass SeatClass? @relation(fields: [seatClassId], references: [id])
seats Seat[]
@@unique([tripId, label])
id String @id @default(uuid())
coachNumber String @unique
label String
seatClassId String
coachType String?
mode String @default("seat") // 'seat', 'bed', 'convertible'
seatArrangement String?
bedArrangement String?
amenities Json?
totalUnits Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
seats Seat[]
assignments CoachAssignment[]
}
model CoachAssignment {
id String @id @default(uuid())
scheduleId String
coachId String
positionNumber Int
isOperational Boolean @default(true)
createdAt DateTime @default(now())
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
coach Coach @relation(fields: [coachId], references: [id])
@@unique([scheduleId, positionNumber])
@@index([scheduleId])
}
model Seat {
id String @id @default(uuid())
coachId String
row Int
col String
label String
seatNumber String?
kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE)
heldUntil DateTime?
isWindow Boolean @default(false)
isAisle Boolean @default(false)
premiumFeeMinor Int @default(0)
eligibility String?
id String @id @default(uuid())
coachId String
row Int
col String
label String
seatNumber String?
kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE)
heldUntil DateTime?
isWindow Boolean @default(false)
isAisle Boolean @default(false)
bedPosition String? // 'lower', 'middle', 'upper'
premiumFeeMinor Int @default(0)
eligibility String?
coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[]
blocks SeatBlock[]
@@ -349,7 +363,7 @@ model Seat {
model SeatHold {
id String @id @default(uuid())
tripId String
scheduleId String
seatIds String[]
fareQuoteId String?
passengerId String
@@ -377,7 +391,7 @@ model Booking {
id String @id @default(uuid())
bookingRef String @unique
passengerId String
tripId String
scheduleId String
status BookingStatus @default(DRAFT)
currency String @default("ETB")
totalMinor Int
@@ -393,7 +407,7 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
trip Trip @relation(fields: [tripId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
@@ -625,15 +639,15 @@ model MenuCategory {
model MenuItem {
id String @id @default(uuid())
tripId String
scheduleId String
categoryId String
name String
priceMinor Int
currency String @default("ETB")
available Boolean @default(true)
availableUntil DateTime?
trip Trip @relation(fields: [tripId], references: [id])
category MenuCategory @relation(fields: [categoryId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
category MenuCategory @relation(fields: [categoryId], references: [id])
}
model FoodOrder {
@@ -747,16 +761,16 @@ model Journey {
}
model JourneySegment {
id String @id @default(uuid())
id String @id @default(uuid())
journeyId String
tripId String
scheduleId String
segmentOrder Int
seatId String?
coachId String?
departureStationId String
arrivalStationId String
journey Journey @relation(fields: [journeyId], references: [id])
trip Trip @relation(fields: [tripId], references: [id])
journey Journey @relation(fields: [journeyId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
}
model OtpCode {
@@ -810,7 +824,7 @@ model RouteStop {
model RouteFareRule {
id String @id @default(uuid())
routeId String
serviceClass ServiceClass
seatClassId String
passengerCategory PassengerCategory @default(ADULT)
baseFareMinor Int
discountPercent Int?
@@ -820,8 +834,9 @@ model RouteFareRule {
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
@@index([routeId, serviceClass])
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
@@index([routeId, seatClassId])
}
model Agent {
@@ -917,13 +932,13 @@ model GateValidationLog {
}
model BaggageAllowance {
id String @id @default(uuid())
serviceClass ServiceClass
id String @id @default(uuid())
seatClassId String
maxWeightKg Int
maxPiecesCount Int
excessFeePerKg Int
currency String @default("ETB")
createdAt DateTime @default(now())
currency String @default("ETB")
createdAt DateTime @default(now())
}
model BaggageBooking {

View File

@@ -1,4 +1,4 @@
import { PrismaClient, ServiceClass, UserRole, LoyaltyTier, SeatKind, PassengerCategory, Currency } from '@prisma/client';
import { PrismaClient, SeatKind } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
@@ -6,130 +6,113 @@ const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting comprehensive seed...');
// All 21 Stations (Ethiopian-Djibouti Railway)
// Stations
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa Central', city: 'Addis Ababa', countryCode: 'ET', lat: 9.0054, lng: 38.7636 } });
const sebeta = await prisma.station.upsert({ where: { code: 'SBT' }, update: {}, create: { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 } });
const labu = await prisma.station.upsert({ where: { code: 'LBU' }, update: {}, create: { code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.8500 } });
const indode = await prisma.station.upsert({ where: { code: 'IND' }, update: {}, create: { code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7833, lng: 39.0167 } });
const bishoftu = await prisma.station.upsert({ where: { code: 'BSH' }, update: {}, create: { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 } });
const mojo = await prisma.station.upsert({ where: { code: 'MJO' }, update: {}, create: { code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.5833, lng: 39.1167 } });
const adama = await prisma.station.upsert({ where: { code: 'ADM' }, update: {}, create: { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 } });
const feto = await prisma.station.upsert({ where: { code: 'FTO' }, update: {}, create: { code: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.7167, lng: 39.5833 } });
const metahara = await prisma.station.upsert({ where: { code: 'MTH' }, update: {}, create: { code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 } });
const awash = await prisma.station.upsert({ where: { code: 'AWS' }, update: {}, create: { code: 'AWS', name: 'Awash', city: 'Awash', countryCode: 'ET', lat: 8.9833, lng: 40.1667 } });
const mieso = await prisma.station.upsert({ where: { code: 'MSO' }, update: {}, create: { code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2333, lng: 40.7500 } });
const bike = await prisma.station.upsert({ where: { code: 'BKE' }, update: {}, create: { code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4167, lng: 41.2500 } });
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 } });
const arawa = await prisma.station.upsert({ where: { code: 'ARW' }, update: {}, create: { code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.0833, lng: 42.2500 } });
const adigala = await prisma.station.upsert({ where: { code: 'ADG' }, update: {}, create: { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.5000, lng: 42.5833 } });
const aysha = await prisma.station.upsert({ where: { code: 'AYS' }, update: {}, create: { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 } });
const dawanle = await prisma.station.upsert({ where: { code: 'DWN' }, update: {}, create: { code: 'DWN', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.3833, lng: 42.8500 } });
const alisabieh = await prisma.station.upsert({ where: { code: 'ALI' }, update: {}, create: { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.1667, lng: 42.7167 } });
const holhol = await prisma.station.upsert({ where: { code: 'HLH' }, update: {}, create: { code: 'HLH', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.4167, lng: 43.0000 } });
const nagad = await prisma.station.upsert({ where: { code: 'NGD' }, update: {}, create: { code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5167, lng: 43.1000 } });
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
// Routes
const route1 = await prisma.route.upsert({
where: { code: 'R001' },
update: {},
create: { code: 'R001', name: 'Addis Ababa - Djibouti Express', effectiveFrom: new Date('2026-01-01'), active: true }
});
// Delete existing route stops and recreate
await prisma.routeStop.deleteMany({ where: { routeId: route1.id } });
await prisma.routeStop.createMany({ data: [
{ routeId: route1.id, stationId: addis.id, sequence: 1, distanceKm: 0 },
{ routeId: route1.id, stationId: sebeta.id, sequence: 2, distanceKm: 23 },
{ routeId: route1.id, stationId: labu.id, sequence: 3, distanceKm: 45 },
{ routeId: route1.id, stationId: indode.id, sequence: 4, distanceKm: 62 },
{ routeId: route1.id, stationId: bishoftu.id, sequence: 5, distanceKm: 47 },
{ routeId: route1.id, stationId: mojo.id, sequence: 6, distanceKm: 73 },
{ routeId: route1.id, stationId: adama.id, sequence: 7, distanceKm: 99 },
{ routeId: route1.id, stationId: feto.id, sequence: 8, distanceKm: 145 },
{ routeId: route1.id, stationId: metahara.id, sequence: 9, distanceKm: 198 },
{ routeId: route1.id, stationId: awash.id, sequence: 10, distanceKm: 225 },
{ routeId: route1.id, stationId: mieso.id, sequence: 11, distanceKm: 305 },
{ routeId: route1.id, stationId: bike.id, sequence: 12, distanceKm: 375 },
{ routeId: route1.id, stationId: direDawa.id, sequence: 13, distanceKm: 453 },
{ routeId: route1.id, stationId: arawa.id, sequence: 14, distanceKm: 520 },
{ routeId: route1.id, stationId: adigala.id, sequence: 15, distanceKm: 580 },
{ routeId: route1.id, stationId: aysha.id, sequence: 16, distanceKm: 656 },
{ routeId: route1.id, stationId: dawanle.id, sequence: 17, distanceKm: 680 },
{ routeId: route1.id, stationId: alisabieh.id, sequence: 18, distanceKm: 700 },
{ routeId: route1.id, stationId: holhol.id, sequence: 19, distanceKm: 730 },
{ routeId: route1.id, stationId: nagad.id, sequence: 20, distanceKm: 750 },
{ routeId: route1.id, stationId: djibouti.id, sequence: 21, distanceKm: 756 },
]});
// Seat Classes
const scEconomyRegular = await prisma.seatClass.upsert({ where: { name: 'Economy Regular' }, update: {}, create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true } });
const scEconomyBed = await prisma.seatClass.upsert({ where: { name: 'Economy Bed' }, update: {}, create: { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 65000, isActive: true } });
const scVipBed = await prisma.seatClass.upsert({ where: { name: 'VIP Bed' }, update: {}, create: { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 95000, isActive: true } });
// Route Fare Rules (with passenger categories)
await prisma.routeFareRule.deleteMany({ where: { routeId: route1.id } });
await prisma.routeFareRule.createMany({ data: [
{ routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', passengerCategory: 'ADULT', baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'ECONOMY_BED_LOWER', passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'VIP_BED_LOWER', passengerCategory: 'ADULT', baseFareMinor: 95000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'ECONOMY_REGULAR', passengerCategory: 'CHILD', baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'ECONOMY_BED_LOWER', passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, serviceClass: 'VIP_BED_LOWER', passengerCategory: 'CHILD', baseFareMinor: 95000, validFrom: new Date('2026-01-01') },
]});
// Trains (logical services)
const train301 = await prisma.train.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301', description: 'Addis-Djibouti Express' } });
const train302 = await prisma.train.upsert({ where: { number: '302' }, update: {}, create: { number: '302', name: 'Express 302', description: 'Djibouti-Addis Express' } });
// Train Services
const service301 = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
const service302 = await prisma.trainService.upsert({ where: { number: '302' }, update: {}, create: { number: '302', name: 'Express 302' } });
// Physical Coaches (reusable)
const coachA1 = await prisma.coach.upsert({ where: { coachNumber: 'C-A1' }, update: {}, create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } });
const coachB1 = await prisma.coach.upsert({ where: { coachNumber: 'C-B1' }, update: {}, create: { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } });
const coachC1 = await prisma.coach.upsert({ where: { coachNumber: 'C-C1' }, update: {}, create: { coachNumber: 'C-C1', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } });
const coachA2 = await prisma.coach.upsert({ where: { coachNumber: 'C-A2' }, update: {}, create: { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } });
const coachB2 = await prisma.coach.upsert({ where: { coachNumber: 'C-B2' }, update: {}, create: { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } });
const coachC2 = await prisma.coach.upsert({ where: { coachNumber: 'C-C2' }, update: {}, create: { coachNumber: 'C-C2', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } });
// Trips (Multiple schedules) - Delete existing trips for clean seed
await prisma.trip.deleteMany({ where: { serviceId: { in: [service301.id, service302.id] } } });
const trip1 = await prisma.trip.create({
data: { serviceId: service301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-15T08:00:00Z'), arrivalAt: new Date('2026-06-15T20:00:00Z'), durationMinutes: 720, stopsCount: 19 },
});
const trip2 = await prisma.trip.create({
data: { serviceId: service302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-16T09:00:00Z'), arrivalAt: new Date('2026-06-16T21:30:00Z'), durationMinutes: 750, stopsCount: 19 },
});
const trip3 = await prisma.trip.create({
data: { serviceId: service301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-17T07:30:00Z'), arrivalAt: new Date('2026-06-17T19:45:00Z'), durationMinutes: 735, stopsCount: 19 },
});
const trip4 = await prisma.trip.create({
data: { serviceId: service302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-18T08:30:00Z'), arrivalAt: new Date('2026-06-18T21:00:00Z'), durationMinutes: 750, stopsCount: 19 },
});
// Trip Stop Times (Major stops only for brevity)
await prisma.tripStopTime.createMany({ data: [
{ tripId: trip1.id, stationId: addis.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T08:00:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: adama.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:30:00Z'), plannedDepartureAt: new Date('2026-06-15T09:45:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: awash.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T11:30:00Z'), plannedDepartureAt: new Date('2026-06-15T11:45:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: direDawa.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: aysha.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T18:00:00Z'), plannedDepartureAt: new Date('2026-06-15T18:10:00Z'), status: 'UPCOMING' },
{ tripId: trip1.id, stationId: djibouti.id, sequence: 21, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), status: 'UPCOMING' },
]});
// Coaches & Seats
for (const trip of [trip1, trip2, trip3, trip4]) {
const coaches = [
{ label: 'A', serviceClass: 'ECONOMY_REGULAR' as ServiceClass, seatCount: 60 },
{ label: 'B', serviceClass: 'ECONOMY_BED_LOWER' as ServiceClass, seatCount: 40 },
{ label: 'C', serviceClass: 'VIP_BED_LOWER' as ServiceClass, seatCount: 20 },
];
for (const { label, serviceClass, seatCount } of coaches) {
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass } });
// Create seats for each physical coach
for (const coach of [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2]) {
const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } });
if (existingSeats === 0) {
const seats = [];
const rows = Math.ceil(seatCount / 4);
const rows = Math.ceil(coach.totalUnits / 4);
for (let row = 1; row <= rows; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
if (seats.length >= seatCount) break;
seats.push({ coachId: coach.id, row, col, label: `${row}${col}`, kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind });
if (seats.length >= coach.totalUnits) break;
seats.push({ coachId: coach.id, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}`, kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind });
}
}
await prisma.seat.createMany({ data: seats });
}
}
// Fare Rules (All trips)
for (const trip of [trip1, trip2, trip3, trip4]) {
await prisma.fareRule.createMany({ data: [
{ tripId: trip.id, serviceClass: 'ECONOMY_REGULAR', baseFareMinor: 45000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: trip.id, serviceClass: 'ECONOMY_BED_LOWER', baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: trip.id, serviceClass: 'VIP_BED_LOWER', baseFareMinor: 95000, validFrom: new Date('2026-01-01'), refundable: true },
]});
// Train Schedules — delete dependents first to avoid FK violations
const existingScheduleIds = (await prisma.trainSchedule.findMany({
where: { trainId: { in: [train301.id, train302.id] } },
select: { id: true },
})).map((s) => s.id);
if (existingScheduleIds.length > 0) {
await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { id: { in: existingScheduleIds } } });
}
const schedule1 = await prisma.trainSchedule.create({
data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-15T08:00:00Z'), arrivalAt: new Date('2026-06-15T20:00:00Z'), durationMinutes: 720, stopsCount: 6 },
});
const schedule2 = await prisma.trainSchedule.create({
data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-16T09:00:00Z'), arrivalAt: new Date('2026-06-16T21:30:00Z'), durationMinutes: 750, stopsCount: 5 },
});
const schedule3 = await prisma.trainSchedule.create({
data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-17T07:30:00Z'), arrivalAt: new Date('2026-06-17T19:45:00Z'), durationMinutes: 735, stopsCount: 5 },
});
const schedule4 = await prisma.trainSchedule.create({
data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-18T08:30:00Z'), arrivalAt: new Date('2026-06-18T21:00:00Z'), durationMinutes: 750, stopsCount: 5 },
});
// Assign coaches to schedules
await prisma.coachAssignment.createMany({
data: [
{ scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 },
{ scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 },
{ scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 },
{ scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 },
{ scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 },
{ scheduleId: schedule4.id, coachId: coachA2.id, positionNumber: 1 },
{ scheduleId: schedule4.id, coachId: coachB2.id, positionNumber: 2 },
{ scheduleId: schedule4.id, coachId: coachC2.id, positionNumber: 3 },
],
skipDuplicates: true,
});
// Stop Times
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule1.id, stationId: addis.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T08:00:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: adama.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T09:30:00Z'), plannedDepartureAt: new Date('2026-06-15T09:45:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: awash.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T11:30:00Z'), plannedDepartureAt: new Date('2026-06-15T11:45:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: direDawa.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: aysha.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T18:00:00Z'), plannedDepartureAt: new Date('2026-06-15T18:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule1.id, stationId: djibouti.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), status: 'UPCOMING' },
],
});
// Fare Rules
for (const schedule of [schedule1, schedule2, schedule3, schedule4]) {
await prisma.fareRule.createMany({
data: [
{ tripId: schedule.id, seatClassId: scEconomyRegular.id, baseFareMinor: 45000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: schedule.id, seatClassId: scEconomyBed.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
{ tripId: schedule.id, seatClassId: scVipBed.id, baseFareMinor: 95000, validFrom: new Date('2026-01-01'), refundable: true },
],
skipDuplicates: true,
});
}
// Users
@@ -137,8 +120,7 @@ async function main() {
const adminHash = await bcrypt.hash('admin123', 10);
const agentHash = await bcrypt.hash('agent123', 10);
const adminUser = await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: adminHash, role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' } });
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: adminHash, role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' } });
const passengerUser = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash, nationality: 'Ethiopian', nationalId: 'ET123456789' } });
let passenger = await prisma.passenger.findUnique({ where: { userId: passengerUser.id } });
if (!passenger) {
@@ -151,105 +133,52 @@ async function main() {
const agentUser = await prisma.user.upsert({ where: { email: 'agent@edr-platform.com' }, update: { passwordHash: agentHash, role: 'AGENT' }, create: { fullName: 'Agent Abebe', email: 'agent@edr-platform.com', phone: '+251911111111', passwordHash: agentHash, role: 'AGENT' } });
await prisma.agent.upsert({ where: { userId: agentUser.id }, update: {}, create: { userId: agentUser.id, agentCode: 'AG001', stationId: addis.id, commissionRate: 5, active: true } });
// Baggage Allowance - Delete and recreate
// Baggage Allowance
await prisma.baggageAllowance.deleteMany({});
await prisma.baggageAllowance.createMany({ data: [
{ serviceClass: 'ECONOMY_REGULAR', maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
{ serviceClass: 'ECONOMY_BED_LOWER', maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
{ serviceClass: 'VIP_BED_LOWER', maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
]});
await prisma.baggageAllowance.createMany({
data: [
{ seatClassId: scEconomyRegular.id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
{ seatClassId: scEconomyBed.id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
{ seatClassId: scVipBed.id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
],
});
// Notification Templates
await prisma.notificationTemplate.upsert({ where: { code: 'BOOKING_CONFIRMED' }, update: {}, create: { code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{tripDate}}.', active: true } });
await prisma.notificationTemplate.upsert({ where: { code: 'PAYMENT_SUCCESS' }, update: {}, create: { code: 'PAYMENT_SUCCESS', channel: 'SMS', bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB', active: true } });
await prisma.notificationTemplate.upsert({ where: { code: 'TRIP_REMINDER' }, update: {}, create: { code: 'TRIP_REMINDER', channel: 'PUSH', subject: 'Trip Reminder', bodyTemplate: 'Your trip departs in {{hours}} hours from {{station}}.', active: true } });
// Promotions
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', subtitle: '15% off all trips', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31'), ctaLabel: 'Book Now', active: true } });
await prisma.promotion.upsert({ where: { code: 'NEWUSER20' }, update: {}, create: { title: 'New User Bonus', code: 'NEWUSER20', percentOff: 20, validUntil: new Date('2026-12-31'), active: true } });
// FAQ - Delete and recreate for clean seed
// FAQ
await prisma.faqArticle.deleteMany({});
await prisma.faqCategory.deleteMany({});
const faqBooking = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'confirmation_number' } });
const faqPayment = await prisma.faqCategory.create({ data: { title: 'Payment & Refunds', iconKey: 'payment' } });
await prisma.faqArticle.createMany({ data: [
{ categoryId: faqBooking.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, select origin and destination stations, choose date, select seats, and proceed to payment.', rank: 1 },
{ categoryId: faqBooking.id, question: 'Can I modify my booking?', answerMarkdown: 'Yes, you can modify your booking up to 24 hours before departure through the Bookings section.', rank: 2 },
{ categoryId: faqPayment.id, question: 'What payment methods are accepted?', answerMarkdown: 'We accept Telebirr, CBE Birr, eBirr, Card, and Wallet payments.', rank: 1 },
{ categoryId: faqPayment.id, question: 'How do refunds work?', answerMarkdown: 'Refunds are processed within 5-7 business days to your original payment method.', rank: 2 },
]});
await prisma.faqArticle.createMany({ data: [{ categoryId: faqBooking.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, select origin and destination stations, choose date, select seats, and proceed to payment.', rank: 1 }] });
// Menu Categories & Items - Delete and recreate
await prisma.menuItem.deleteMany({});
await prisma.menuCategory.deleteMany({});
const menuBeverages = await prisma.menuCategory.create({ data: { name: 'Beverages' } });
const menuSnacks = await prisma.menuCategory.create({ data: { name: 'Snacks' } });
await prisma.menuItem.createMany({ data: [
{ tripId: trip1.id, categoryId: menuBeverages.id, name: 'Coffee', priceMinor: 2500, available: true },
{ tripId: trip1.id, categoryId: menuBeverages.id, name: 'Tea', priceMinor: 2000, available: true },
{ tripId: trip1.id, categoryId: menuSnacks.id, name: 'Sandwich', priceMinor: 5000, available: true },
]});
// Station Crowd Signals - Delete and recreate
// Station Crowd Signals
await prisma.stationCrowdSignal.deleteMany({});
await prisma.stationCrowdSignal.createMany({ data: [
{ stationId: addis.id, level: 'MODERATE', label: 'Moderate', statusLabel: 'Normal operations' },
{ stationId: adama.id, level: 'LOW', label: 'Low', statusLabel: 'Quiet' },
{ stationId: direDawa.id, level: 'LOW', label: 'Low', statusLabel: 'Quiet' },
{ stationId: djibouti.id, level: 'HIGH', label: 'High', statusLabel: 'Busy terminal' },
]});
await prisma.stationCrowdSignal.createMany({ data: [{ stationId: addis.id, level: 'MODERATE', label: 'Moderate', statusLabel: 'Normal operations' }, { stationId: djibouti.id, level: 'HIGH', label: 'High', statusLabel: 'Busy terminal' }] });
// Fraud Detection Rules
await prisma.fraudRule.upsert({ where: { type: 'VELOCITY' }, update: {}, create: { type: 'VELOCITY', enabled: true, threshold: 3, config: { windowMinutes: 60, action: 'FLAG' } } });
await prisma.fraudRule.upsert({ where: { type: 'HIGH_VALUE' }, update: {}, create: { type: 'HIGH_VALUE', enabled: true, threshold: 500000, config: { action: 'REVIEW' } } });
await prisma.fraudRule.upsert({ where: { type: 'FAILED_PAYMENTS' }, update: {}, create: { type: 'FAILED_PAYMENTS', enabled: true, threshold: 5, config: { windowMinutes: 1440, action: 'BLOCK' } } });
// Loyalty Rewards (linked to loyalty account) - Delete and recreate
if (passenger) {
const loyaltyAccount = await prisma.loyaltyAccount.findUnique({ where: { passengerId: passenger.id } });
if (loyaltyAccount) {
await prisma.loyaltyReward.deleteMany({ where: { accountId: loyaltyAccount.id } });
await prisma.loyaltyReward.createMany({ data: [
{ accountId: loyaltyAccount.id, title: '10% Discount Voucher', costPoints: 1000, available: true, description: 'Get 10% off your next booking' },
{ accountId: loyaltyAccount.id, title: 'Free Upgrade to VIP', costPoints: 2500, available: true, description: 'Upgrade to VIP class on any trip' },
{ accountId: loyaltyAccount.id, title: '500 ETB Wallet Credit', costPoints: 5000, available: true, description: 'Add 500 ETB to your wallet' },
]});
}
}
// Currency Exchange Rates
await prisma.currencyExchangeRate.deleteMany({});
await prisma.currencyExchangeRate.createMany({ data: [
{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.25, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() },
{ fromCurrency: 'DJF', toCurrency: 'ETB', rate: 0.3077, effectiveDate: new Date() },
{ fromCurrency: 'USD', toCurrency: 'ETB', rate: 55.56, effectiveDate: new Date() },
]});
await prisma.currencyExchangeRate.createMany({ data: [{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() }, { fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() }] });
console.log('✅ Comprehensive seed complete');
console.log('\n📋 Seed Summary:');
console.log(' - 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)');
console.log(' - 1 Route with 21 stops');
console.log(' - 2 Train services, 4 trips');
console.log(' - 3 Coaches per trip (Economy, Bed, VIP)');
console.log(' - Fare rules for ADULT and CHILD categories');
console.log(' - Currency exchange rates (ETB ↔ DJF, USD)');
console.log(' - 2 Trains (Express 301, Express 302)');
console.log(' - 6 Physical Coaches (reusable across schedules)');
console.log(' - 4 Train Schedules with coach assignments');
console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)');
console.log(' - 3 Users: Admin, Passenger (Silver tier + wallet), Agent');
console.log(' - 3 Fraud detection rules');
console.log(' - 3 Loyalty rewards');
console.log(' - Baggage rules, Notification templates, Promotions, FAQ');
console.log('\n🔑 Login Credentials:');
console.log(' Admin: admin@edr-platform.com / admin123');
console.log(' Passenger: kelemu@email.com / password123');
console.log(' Agent: agent@edr-platform.com / agent123');
console.log('\n🚉 Stations: Addis Ababa → Sebeta → Labu → Indode → Bishoftu → Mojo → Adama → Feto → Metahara → Awash → Mieso → Bike → Dire Dawa → Arawa → Adigala → Aysha → Dawanle → Alisabieh → Holhol → Nagad → Djibouti');
console.log('\n💰 Pricing: ADULT (≥5 years) = 100% fare | CHILD (<5 years) = First free, subsequent 100%');
console.log('\n💱 Currencies: ETB (transaction) | DJF, USD (display) | Rates: ETB→DJF=3.25, ETB→USD=0.018');
console.log('\n🔐 Verifayda: DISABLED (set VERIFAYDA_ENABLED=true in production)');
console.log('\n🚂 Architecture: Train → TrainSchedule ↔ CoachAssignment ↔ Coach → Seat');
}
main().catch(console.error).finally(() => prisma.$disconnect());