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(); 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): Promise { 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]> = [ ['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(); });