Files
edr-platform/apps/edr-passenger-api/prisma/seed.ts
2026-07-02 22:42:15 +03:00

907 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { randomUUID as uuidv4 } from 'crypto';
const prisma = new PrismaClient();
const TRAIN_ID = uuidv4();
async function seedSystemUsers() {
console.log('👥 Seeding system users...');
const adminHash = await bcrypt.hash('admin123', 10);
const passengerHash = await bcrypt.hash('password123', 10);
const agentHash = await bcrypt.hash('agent123', 10);
const supervisorHash = await bcrypt.hash('supervisor123', 10);
const staffHash = await bcrypt.hash('staff123', 10);
const admin = await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash: adminHash, role: 'ADMIN' },
create: {
fullName: 'System Admin',
email: 'admin@edr-platform.com',
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');
const passenger = await prisma.user.upsert({
where: { email: 'kelemu@email.com' },
update: { passwordHash: passengerHash },
create: {
fullName: 'Kelemu Kebede',
email: 'kelemu@email.com',
phone: '+251911234567',
passwordHash: passengerHash,
role: 'PASSENGER',
nationality: 'Ethiopian',
faydaVerified: true,
gender: 'Male',
dateOfBirth: new Date('1990-03-15'),
nationalId: 'ET987654321',
},
});
let passengerRecord = await prisma.passenger.findUnique({ where: { userId: passenger.id } });
if (!passengerRecord) {
passengerRecord = await prisma.passenger.create({ data: { userId: passenger.id } });
await prisma.loyaltyAccount.create({
data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' },
});
await prisma.walletAccount.create({
data: { passengerId: passengerRecord.id, balanceMinor: 500 },
});
}
await prisma.userPreferences.upsert({
where: { iamUserId: passenger.id },
update: {},
create: { iamUserId: passenger.id, language: 'en' },
});
console.log(' ✅ Passenger: kelemu@email.com / password123');
const agent = await prisma.user.upsert({
where: { email: 'agent@edr-platform.com' },
update: { passwordHash: agentHash, role: 'AGENT' },
create: {
fullName: 'Booking Agent',
email: 'agent@edr-platform.com',
phone: '+251911111111',
passwordHash: agentHash,
role: 'AGENT',
gender: 'Female',
dateOfBirth: new Date('1992-07-22'),
},
});
await prisma.agent.upsert({
where: { agentCode: 'AG0001' },
update: {},
create: { agentCode: 'AG0001', commissionRate: 5 },
});
console.log(' ✅ Agent: agent@edr-platform.com / agent123');
const supervisor = await prisma.user.upsert({
where: { email: 'supervisor@edr-platform.com' },
update: { passwordHash: supervisorHash, role: 'SUPERVISOR' },
create: {
fullName: 'System Supervisor',
email: 'supervisor@edr-platform.com',
phone: '+251922222222',
passwordHash: supervisorHash,
role: 'SUPERVISOR',
gender: 'Male',
dateOfBirth: new Date('1985-11-10'),
},
});
console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123');
const staff = await prisma.user.upsert({
where: { email: 'staff@edr-platform.com' },
update: { passwordHash: staffHash, role: 'STAFF' },
create: {
fullName: 'Support Staff',
email: 'staff@edr-platform.com',
phone: '+251933333333',
passwordHash: staffHash,
role: 'STAFF',
gender: 'Female',
dateOfBirth: new Date('1995-09-08'),
},
});
console.log(' ✅ Staff: staff@edr-platform.com / staff123');
}
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, 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) {
await prisma.station.upsert({
where: { code: station.code },
update: {},
create: {
id: uuidv4(),
...station,
},
});
}
console.log(`${stations.length} stations created`);
return stations;
}
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 Berth Coach', type: 'Economy Bed' },
{ code: 'SBC', name: 'Soft Berth Coach', type: 'VIP Bed' },
];
for (const ct of coachTypes) {
await prisma.coachType.upsert({
where: { id: ct.code },
update: {},
create: { ...ct, id: ct.code },
});
}
// Tariff rates: baseFareMinor = tariff_decimal × 100000
// Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate
// LOCAL = Ethiopian or Djiboutian nationals
// INTERNATIONAL = all other nationalities
const seatClasses = [
// LOCAL rates
{ name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
// INTERNATIONAL rates
{ name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 },
];
for (const sc of seatClasses) {
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
await prisma.seatClass.upsert({
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
create: {
coachTypeId: ct!.id,
name: sc.name,
nationalityType: sc.nationalityType,
bedPosition: sc.bedPosition,
baseFareMinor: sc.baseFareMinor,
premiumMinor: sc.premiumMinor,
insuranceFeeMinor: sc.insuranceFeeMinor,
},
});
}
console.log(`${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
}
async function seedRoute() {
console.log('\n🛣 Seeding route and stops...');
const route = await prisma.route.upsert({
where: { code: 'Route-101' },
update: {},
create: {
code: 'Route-101',
name: 'Sebeta - Dire Dawa',
description: 'Outbound local route from Sebeta to Dire Dawa',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
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: { distanceKm: routeDistancesKm[i] },
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
});
}
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'];
// Cumulative distances from origin (Dire Dawa), mirroring the outbound route in reverse
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: { distanceKm: returnRouteDistancesKm[i] },
create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] },
});
}
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
// Full cross-border route: Sebeta → Nagad (all 15 stations)
const fullRoute = await prisma.route.upsert({
where: { code: 'Route-201' },
update: {},
create: {
code: 'Route-201',
name: 'Sebeta - Nagad (Full Cross-Border)',
description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
// Cumulative distances from Sebeta (km) for all 15 stations
const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0];
for (let i = 0; i < fullStationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } },
update: { distanceKm: fullDistancesKm[i] },
create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] },
});
}
// Full cross-border return route: Nagad → Sebeta
const fullReturnRoute = await prisma.route.upsert({
where: { code: 'Route-202' },
update: {},
create: {
code: 'Route-202',
name: 'Nagad - Sebeta (Full Cross-Border Return)',
description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0];
for (let i = 0; i < fullReturnStationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } },
update: { distanceKm: fullReturnDistancesKm[i] },
create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] },
});
}
console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`);
}
async function seedCoaches() {
console.log('\n🚃 Seeding coaches and seats...');
const ecoCoachType = await prisma.coachType.findUnique({ where: { id: 'HSC' } });
const ecoBedCoachType = await prisma.coachType.findUnique({ where: { id: 'HBC' } });
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
const coaches = [
{ 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;
for (const coach of coaches) {
const c = await prisma.coach.upsert({
where: { number: coach.number },
update: {},
create: coach,
});
// Idempotently reconcile the coach's seats. Upsert keyed on the
// @@unique([coachId, row, col]) constraint so a re-seed updates existing
// rows in place instead of deleting them. Deleting Seats fails with a P2003
// 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', 'E']) {
if (seatIndex > coach.capacity) break;
let bedPosition: string | null = null;
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 === 'E',
isAisle: col === 'B' || col === 'C' || col === 'D',
bedPosition,
};
await prisma.seat.upsert({
where: { coachId_row_col: { coachId: c.id, row, col } },
update: seatData,
create: { coachId: c.id, row, col, ...seatData },
});
seatIndex++;
}
}
totalSeats += coach.capacity;
}
console.log(`${coaches.length} coaches with ${totalSeats} seats created`);
}
async function seedTrips() {
console.log('\n🚆 Seeding train service, schedule, and trips...');
const train = await prisma.train.upsert({
where: { number: 'EDR-001' },
update: {},
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Express Service' },
});
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 tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const schedules = [];
for (let d = 0; d < 5; d++) {
const tripDate = new Date(now);
tripDate.setDate(tripDate.getDate() + d);
tripDate.setHours(20, 30, 0, 0);
schedules.push({
trainId: train.id,
routeId: route!.id,
originStationId: firstStation!.id,
destinationStationId: lastStation!.id,
departureAt: new Date(tripDate),
arrivalAt: new Date(tripDate), // patched below
durationMinutes: 0, // patched below
stopsCount: 9,
});
}
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 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() - 5 * 60_000); // 5 min dwell
return {
scheduleId: schedule.id,
stationId: stop.stationId,
sequence: stop.sequence,
plannedArrivalAt,
plannedDepartureAt,
};
});
// 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 = [];
const liveStatuses = [];
for (const schedule of createdSchedules) {
for (let p = 0; p < coaches.length; p++) {
coachAssignments.push({
scheduleId: schedule.id,
coachId: coaches[p].id,
positionNumber: p + 1,
});
}
liveStatuses.push({
scheduleId: schedule.id,
state: 'scheduled',
progressPercent: 0,
});
}
await Promise.all([
...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })),
...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })),
]);
console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`);
}
async function seedFareRules() {
console.log('\n💰 Seeding fare rules...');
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
const seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01');
// Delete existing FareRule rows so re-seed is idempotent
await prisma.fareRule.deleteMany({});
const fareRules: any[] = [];
for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
for (const sc of seatClasses) {
fareRules.push({
route: route.code,
seatClassId: sc.id,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
}
}
await Promise.all(
fareRules.map(fr => prisma.fareRule.create({ data: fr }))
);
console.log(`${fareRules.length} fare rules created in FareRule table`);
const allRoutes = await prisma.route.findMany({
where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } },
});
const routeFareRules: any[] = [];
for (const r of allRoutes) {
for (const sc of seatClasses) {
routeFareRules.push({
routeId: r.id,
seatClassId: sc.id,
passengerCategory: 'ADULT' as const,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
// CHILD: same per-km rate as ADULT — age-based free/paid logic is handled
// at booking time (first child free, subsequent children full fare).
routeFareRules.push({
routeId: r.id,
seatClassId: sc.id,
passengerCategory: 'CHILD' as const,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
}
}
await Promise.all(
routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {}))
);
console.log(`${routeFareRules.length} route fare rules for ADULT/CHILD categories created`);
}
async function seedCurrency() {
console.log('\n💱 Seeding currency exchange rates...');
const rates = [
{ from: 'ETB', to: 'DJF', rate: 3.25 },
{ from: 'ETB', to: 'USD', rate: 0.018 },
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
{ from: 'USD', to: 'ETB', rate: 55.56 },
];
for (const r of rates) {
await prisma.currencyExchangeRate.upsert({
where: {
fromCurrency_toCurrency_effectiveDate: {
fromCurrency: r.from as any,
toCurrency: r.to as any,
effectiveDate: new Date('2024-01-01'),
},
},
update: { rate: r.rate },
create: {
fromCurrency: r.from as any,
toCurrency: r.to as any,
rate: r.rate,
effectiveDate: new Date('2024-01-01'),
},
});
}
console.log(` ✅ 4 currency exchange rates created`);
}
async function seedPaymentMethods() {
console.log('\n💳 Seeding payment methods...');
const methods = [
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
{ type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI' },
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
];
for (const m of methods) {
await prisma.paymentMethod.upsert({
where: { type: m.type as any },
update: {},
create: { ...m, type: m.type as any, region: m.region as any },
});
}
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('2026-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...');
// NOTE: `code` must match the templateKey passed by NotificationsService.send(...).
// The event-driven handlers use the dotted event names (booking.created, payment.succeeded).
const templates = [
{ id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' },
{ id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
{ id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
{ id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
// Templates below are not wired to handlers yet (Phase 2 — full event coverage).
{ id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', 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.offer', channel: 'PUSH', subject: 'Special Offer', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
];
for (const t of templates) {
await prisma.notificationTemplate.upsert({
where: { code: t.code },
// Refresh the editable fields on re-seed so template tweaks actually take effect.
update: { channel: t.channel, subject: t.subject ?? null, bodyTemplate: t.bodyTemplate, active: true },
create: t,
});
}
console.log(`${templates.length} notification templates created`);
}
async function seedMenuAndFood() {
console.log('\n🍽 Seeding menu categories and items...');
const beverages = await prisma.menuCategory.upsert({
where: { id: uuidv4() },
update: {},
create: { id: uuidv4(), name: 'Beverages' },
});
const snacks = await prisma.menuCategory.upsert({
where: { id: uuidv4() },
update: {},
create: { id: uuidv4(), name: 'Snacks' },
});
const schedule = await prisma.trainSchedule.findFirst();
if (schedule) {
const coffeeId = uuidv4();
const juiceId = uuidv4();
const sandwichId = uuidv4();
await prisma.menuItem.create({
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: 35 },
}).catch(() => { }); // ignore if exists
await prisma.menuItem.create({
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
}).catch(() => { }); // ignore if exists
}
console.log(` ✅ Menu categories and items created`);
}
async function seedPromotions() {
console.log('\n🎉 Seeding promotions...');
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: 100, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
];
for (const p of promos) {
await prisma.promotion.upsert({
where: { code: p.code },
update: {},
create: p,
});
}
console.log(`${promos.length} promotions created`);
}
async function seedFAQ() {
console.log('\n❓ Seeding FAQ...');
const generalId = uuidv4();
const bookingId = uuidv4();
const general = await prisma.faqCategory.upsert({
where: { id: generalId },
update: {},
create: { id: generalId, title: 'General', iconKey: 'help' },
});
const booking = await prisma.faqCategory.upsert({
where: { id: bookingId },
update: {},
create: { id: bookingId, title: 'Booking', iconKey: 'book' },
});
await prisma.faqArticle.upsert({
where: { id: uuidv4() },
update: {},
create: { id: uuidv4(), categoryId: general.id, question: 'What is EDR?', answerMarkdown: 'Ethio-Djibouti Railway' },
});
await prisma.faqArticle.upsert({
where: { id: uuidv4() },
update: {},
create: { id: uuidv4(), categoryId: booking.id, question: 'How to book?', answerMarkdown: 'Use the booking system' },
});
console.log(` ✅ FAQ categories and articles created`);
}
async function seedFraudRules() {
console.log('\n🔐 Seeding fraud detection rules...');
const rules = [
{ type: 'RAPID_BOOKINGS', threshold: 10, enabled: true },
{ type: 'HIGH_VALUE_BOOKING', threshold: 500000, enabled: true },
{ type: 'UNUSUAL_DEVICE', threshold: 0.8, enabled: true },
];
for (const r of rules) {
await prisma.fraudRule.upsert({
where: { type: r.type },
update: {},
create: r,
});
}
console.log(`${rules.length} fraud detection rules created`);
}
async function seedKulubbiPackage() {
console.log('\n🚆 Seeding Kulubbi Gabriel 2025 package...');
const addisStation = await prisma.station.findFirst({ where: { code: 'SBT' } });
const direDawaStation = await prisma.station.findFirst({ where: { code: 'DRE' } });
if (!addisStation || !direDawaStation) {
console.log(' ⚠️ Stations not found, skipping Kulubbi package seed');
return;
}
// Use the first two schedules as outbound/return (or create dedicated ones)
const schedules = await prisma.trainSchedule.findMany({ take: 2, orderBy: { departureAt: 'asc' } });
if (schedules.length < 2) {
console.log(' ⚠️ Not enough schedules found, skipping Kulubbi package seed');
return;
}
const [outboundSchedule, returnSchedule] = schedules;
await prisma.travelPackage.upsert({
where: { code: 'KULUBI-2025' },
update: {
validFrom: new Date(),
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
status: 'ACTIVE',
},
create: {
code: 'KULUBBI-2025',
name: 'Kulubbi Gabriel Pilgrimage Package',
description: 'Annual pilgrimage round-trip package to Kulubi Gabriel Church. Includes train travel, bus transfer, meals, and entertainment.',
outboundScheduleId: outboundSchedule.id,
returnScheduleId: returnSchedule.id,
originStationId: addisStation.id,
destinationStationId: direDawaStation.id,
boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
totalCapacity: 912,
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
busTransferIncluded: true,
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
validFrom: new Date(),
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
status: 'ACTIVE',
includedServices: [
'Round-trip train travel (Addis Ababa ↔ Dire Dawa)',
'Lunch served on board',
'Refreshments and bottled water',
'Round-trip bus transfer (Dire Dawa ↔ Kulubi Gabriel)',
'Onboard first aid and medical support',
'Entertainment (audio/video)',
'Service briefing and pilgrimage guidance',
'Pick-up and drop-off coordination',
],
priceTiers: {
create: [
{ seatType: 'HSC', label: 'Regular Seat (HSC)', priceMinor: 1023200, availableSeats: 550 },
{ seatType: 'ECU', label: 'Economic Bed Upper (ECU)', priceMinor: 1295200, availableSeats: 80 },
{ seatType: 'ECM', label: 'Economic Bed Middle (ECM)', priceMinor: 1364000, availableSeats: 80 },
{ seatType: 'ECL', label: 'Economic Bed Lower (ECL)', priceMinor: 1430200, availableSeats: 80 },
{ seatType: 'VIU', label: 'VIP Bed Upper (VIU)', priceMinor: 1243500, availableSeats: 61 },
{ seatType: 'VIL', label: 'VIP Bed Lower (VIL)', priceMinor: 1643500, availableSeats: 61 },
],
},
},
});
console.log(' ✅ Kulubbi Gabriel 2025 package created');
}
// Run a seed step in isolation: if it throws (FK conflict, duplicate row,
// missing record, etc.) log the error and keep going so the rest of the seed —
// and the API startup that follows it — are never blocked by one bad step.
async function runStep(name: string, step: () => Promise<unknown>): Promise<boolean> {
try {
await step();
return true;
} catch (e) {
console.error(`⚠️ Seed step "${name}" failed — skipping and continuing:`, e);
return false;
}
}
async function main() {
console.log('🌱 Comprehensive EDR Seed Starting...\n');
const steps: Array<[string, () => Promise<unknown>]> = [
['System Users', seedSystemUsers],
['Stations', seedStations],
['Coach Types & Classes', seedCoachTypesAndClasses],
['Route', seedRoute],
['Coaches', seedCoaches],
['Trips', seedTrips],
['Fare Rules', seedFareRules],
['Currency', seedCurrency],
['Payment Methods', seedPaymentMethods],
['Segment Fares', seedSegmentFares],
['Notification Templates', seedNotificationTemplates],
['Menu & Food', seedMenuAndFood],
['Promotions', seedPromotions],
['FAQ', seedFAQ],
['Fraud Rules', seedFraudRules],
['Kulubbi Package', seedKulubbiPackage],
];
let failed = 0;
for (const [name, step] of steps) {
if (!(await runStep(name, step))) failed++;
}
if (failed > 0) {
console.warn(`\n⚠ Seed finished with ${failed}/${steps.length} step(s) failed (see logs above).\n`);
} else {
console.log('\n✅ Seed complete!\n');
}
console.log('🔑 System Users:');
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(' Supervisor: supervisor@edr-platform.com / supervisor123');
console.log(' Staff: staff@edr-platform.com / staff123');
}
main()
.catch((e) => {
// Intentionally do NOT process.exit(1): the docker entrypoint runs under
// `set -e`, so a non-zero exit here would abort container startup and the
// API would never boot. Log and exit cleanly instead.
console.error('❌ Seed crashed unexpectedly — continuing so the API can start:', e);
})
.finally(async () => {
await prisma.$disconnect();
});