mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
790 lines
30 KiB
TypeScript
790 lines
30 KiB
TypeScript
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 },
|
||
});
|
||
}
|
||
|
||
const seatClasses = [
|
||
{ 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) {
|
||
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
|
||
await prisma.seatClass.upsert({
|
||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||
update: {},
|
||
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`);
|
||
}
|
||
|
||
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: {},
|
||
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'];
|
||
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() {
|
||
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 seatClasses = await prisma.seatClass.findMany();
|
||
const validFrom = new Date('2024-01-01');
|
||
|
||
const fareRules = [];
|
||
for (const sc of seatClasses) {
|
||
fareRules.push({
|
||
routeId: route!.id,
|
||
seatClassId: sc.id,
|
||
passengerCategory: 'ADULT' as const,
|
||
baseFareMinor: sc.baseFareMinor,
|
||
currency: 'ETB',
|
||
validFrom,
|
||
});
|
||
fareRules.push({
|
||
routeId: route!.id,
|
||
seatClassId: sc.id,
|
||
passengerCategory: 'CHILD' as const,
|
||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
||
discountPercent: 10,
|
||
currency: 'ETB',
|
||
validFrom,
|
||
});
|
||
}
|
||
|
||
await Promise.all(
|
||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
||
);
|
||
console.log(` ✅ ${fareRules.length} 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: 'KULUBBI-2025' },
|
||
update: {},
|
||
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('2025-07-24T07:00:00+03:00'),
|
||
departureTime: new Date('2025-07-24T09:00:00+03:00'),
|
||
arrivalTime: new Date('2025-07-25T06:00:00+03:00'),
|
||
totalCapacity: 912,
|
||
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
|
||
busTransferIncluded: true,
|
||
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
|
||
validFrom: new Date('2025-07-01'),
|
||
validUntil: new Date('2025-07-24T09:00:00+03:00'),
|
||
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],
|
||
['fare rules', seedFareRules],
|
||
['segment fares', seedSegmentFares],
|
||
['currency', seedCurrency],
|
||
['notification templates', seedNotificationTemplates],
|
||
['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();
|
||
});
|