From 6d73359b8e377cd3dae9aed00be60ae2adef674d Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 17 Jun 2026 20:22:09 +0300 Subject: [PATCH 01/15] update booking request object --- .../portal/src/app/booking/review/page.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index d3e75d7d9..6b09ec422 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -144,9 +144,14 @@ export default function ReviewPage() { const createBookingMutation = useMutation({ mutationFn: (data: any) => { const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest'; + console.log('=== API REQUEST ==='); + console.log('Endpoint:', endpoint); + console.log('Request Data:', JSON.stringify(data, null, 2)); return apiClient.post(endpoint, data); }, onSuccess: (data: any) => { + console.log('=== API RESPONSE SUCCESS ==='); + console.log('Response Data:', JSON.stringify(data, null, 2)); console.log('Booking created successfully:', data); const bookingIdValue = data.bookingId || data.id; const pnrValue = data.pnr || data.bookingReference || data.bookingRef; @@ -178,7 +183,12 @@ export default function ReviewPage() { }, 100); }, onError: (error: any) => { - console.error('Booking creation failed:', error); + console.log('=== API RESPONSE ERROR ==='); + console.error('Error Object:', error); + console.error('Error Response:', error?.response); + console.error('Error Response Data:', JSON.stringify(error?.response?.data, null, 2)); + console.error('Error Status:', error?.response?.status); + console.error('Error Message:', error?.message); const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.'; alert(errorMessage); }, @@ -196,7 +206,7 @@ export default function ReviewPage() { console.log('Inbound schedule:', inboundSchedule); console.log('Passengers:', passengers); - if (!seatHold?.holdId) { + if (!seatHold?.holdId && (passengers.some(p => p.seatId) || passengers.some(p => (p as any).outboundSeatId || (p as any).inboundSeatId))) { console.error('No seat hold found'); alert('Please select seats before continuing.'); router.push('/booking/seats'); @@ -285,6 +295,7 @@ export default function ReviewPage() { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), + ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -323,6 +334,7 @@ export default function ReviewPage() { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), + ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -358,7 +370,8 @@ export default function ReviewPage() { localStorage.setItem('deviceId', bookingData.deviceId); } - console.log('Creating booking with payload:', bookingData); + console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2)); + console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest'); await createBookingMutation.mutateAsync(bookingData); } catch (error) { console.error('Error in handleConfirm:', error); From b491f39d4266dcb29b8aec02cfff5346a667b0ca Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 18 Jun 2026 14:02:11 +0300 Subject: [PATCH 02/15] Booking and pricing related updates --- .../migration.sql | 78 ++- apps/edr-passenger-api/prisma/schema.prisma | 52 +- apps/edr-passenger-api/prisma/seed.ts | 29 +- .../modules/fare-engine/fare-engine.dto.ts | 3 + .../fare-engine/fare-engine.service.ts | 99 +++- .../modules/schedules/schedules.controller.ts | 141 ++--- .../modules/schedules/schedules.service.ts | 111 ++-- .../src/modules/search/search.service.ts | 1 + .../backoffice/src/app/currencies/page.tsx | 507 +++++------------- .../backoffice/src/app/pricing/page.tsx | 15 +- 10 files changed, 410 insertions(+), 626 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql index 4f828f075..f252642f6 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql @@ -1,28 +1,52 @@ --- Fix missing columns from 20260617 migration (failed due to missing schema prefix) +-- Create passenger schema if it doesn't exist +CREATE SCHEMA IF NOT EXISTS passenger; + +-- Move all enums from public to passenger schema +DO $$ +DECLARE + e text; +BEGIN + FOR e IN + SELECT typname FROM pg_type + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e' + LOOP + EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); + END LOOP; +END $$; + +-- Move all tables from public to passenger schema +DO $$ +DECLARE + t text; +BEGIN + FOR t IN + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations') + LOOP + EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); + END LOOP; +END $$; + +-- Add missing columns to Booking ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT, ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT, ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT, ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT, - ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT; + ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); -ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; - -CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); - --- Transit leg-2 columns (never migrated) -ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT; - --- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed) +-- Add ReturnLegStatus enum and column DO $$ BEGIN CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ( 'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED' @@ -30,9 +54,15 @@ DO $$ BEGIN EXCEPTION WHEN duplicate_object THEN NULL; END $$; ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', - ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), - ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); + ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE'; -ALTER TABLE "passenger"."GateValidationLog" - ADD COLUMN IF NOT EXISTS "leg" TEXT; +-- Add missing columns to other tables +ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT; +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT; +ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); + +ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; +ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 1b98bd3e1..423a03ec9 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["multiSchema"] } datasource db { @@ -79,7 +80,6 @@ model CoachType { updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] - @@schema("passenger") } @@ -116,11 +116,11 @@ enum BookingStatus { } enum ReturnLegStatus { - NOT_APPLICABLE // one-way booking - BOTH_USED // passenger used both legs - OUTBOUND_ONLY // return leg not used (no-show on return) - INBOUND_ONLY // outbound leg not used, return leg used - NEITHER_USED // neither leg boarded yet + NOT_APPLICABLE + BOTH_USED + OUTBOUND_ONLY + INBOUND_ONLY + NEITHER_USED @@schema("passenger") } @@ -269,7 +269,6 @@ model User { fraudAlerts FraudAlert[] faydaVerificationSessions FaydaVerificationSession[] - @@schema("passenger") } @@ -283,7 +282,6 @@ model Session { lastActivityAt DateTime @default(now()) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@schema("passenger") } @@ -315,7 +313,6 @@ model TravelerProfile { notes String? createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -350,7 +347,6 @@ model Train { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt schedules TrainSchedule[] - @@schema("passenger") } @@ -412,7 +408,6 @@ model TripLiveStatus { platformLabel String? updatedAt DateTime @updatedAt schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - @@schema("passenger") } @@ -500,7 +495,6 @@ model FareRule { validFrom DateTime validUntil DateTime? createdAt DateTime @default(now()) - @@schema("passenger") } @@ -584,7 +578,6 @@ model BookingSeat { displayFareMinor Int? booking Booking @relation(fields: [bookingId], references: [id]) seat Seat @relation(fields: [seatId], references: [id]) - @@schema("passenger") } @@ -600,7 +593,6 @@ model PaymentMethod { sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@schema("passenger") } @@ -661,7 +653,6 @@ model PaymentRefund { status String createdAt DateTime @default(now()) paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) - @@schema("passenger") } @@ -681,7 +672,6 @@ model Ticket { booking Booking @relation(fields: [bookingId], references: [id]) validationLogs GateValidationLog[] seats TicketSeat[] - @@schema("passenger") } @@ -709,7 +699,6 @@ model LoyaltyAccount { passenger Passenger @relation(fields: [passengerId], references: [id]) ledger LoyaltyLedgerEntry[] rewards LoyaltyReward[] - @@schema("passenger") } @@ -722,7 +711,6 @@ model LoyaltyLedgerEntry { balanceAfter Int createdAt DateTime @default(now()) account LoyaltyAccount @relation(fields: [accountId], references: [id]) - @@schema("passenger") } @@ -734,7 +722,6 @@ model LoyaltyReward { available Boolean @default(true) description String? account LoyaltyAccount @relation(fields: [accountId], references: [id]) - @@schema("passenger") } @@ -763,7 +750,6 @@ model WalletLedgerEntry { relatedBookingId String? createdAt DateTime @default(now()) wallet WalletAccount @relation(fields: [walletId], references: [id]) - @@schema("passenger") } @@ -778,7 +764,6 @@ model Notification { metadata Json? createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -794,7 +779,6 @@ model Promotion { deepLink String? active Boolean @default(true) createdAt DateTime @default(now()) - @@schema("passenger") } @@ -808,7 +792,6 @@ model StationCrowdSignal { observedAt DateTime? updatedAt DateTime @updatedAt station Station @relation(fields: [stationId], references: [id]) - @@schema("passenger") } @@ -820,7 +803,6 @@ model WeatherAlert { message String validUntil DateTime createdAt DateTime @default(now()) - @@schema("passenger") } @@ -828,7 +810,6 @@ model MenuCategory { id String @id @default(uuid()) name String items MenuItem[] - @@schema("passenger") } @@ -843,7 +824,6 @@ model MenuItem { availableUntil DateTime? schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) category MenuCategory @relation(fields: [categoryId], references: [id]) - @@schema("passenger") } @@ -858,7 +838,6 @@ model FoodOrder { createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) items FoodOrderItem[] - @@schema("passenger") } @@ -871,7 +850,6 @@ model FoodOrderItem { unitPriceMinor Int? lineTotalMinor Int order FoodOrder @relation(fields: [orderId], references: [id]) - @@schema("passenger") } @@ -880,7 +858,6 @@ model FaqCategory { title String iconKey String? articles FaqArticle[] - @@schema("passenger") } @@ -891,7 +868,6 @@ model FaqArticle { answerMarkdown String rank Int @default(0) category FaqCategory @relation(fields: [categoryId], references: [id]) - @@schema("passenger") } @@ -902,7 +878,6 @@ model SupportConversation { status SupportConversationStatus @default(OPEN) createdAt DateTime @default(now()) messages SupportMessage[] - @@schema("passenger") } @@ -914,7 +889,6 @@ model SupportMessage { attachments Json? createdAt DateTime @default(now()) conversation SupportConversation @relation(fields: [conversationId], references: [id]) - @@schema("passenger") } @@ -934,7 +908,6 @@ model UserPreferences { darkMode Boolean @default(false) language String @default("en") user User @relation(fields: [userId], references: [id]) - @@schema("passenger") } @@ -947,7 +920,6 @@ model Device { trusted Boolean @default(false) lastSeenAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) - @@schema("passenger") } @@ -961,7 +933,6 @@ model SavedRoute { tripCount Int @default(0) createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -973,7 +944,6 @@ model Journey { currency String @default("ETB") createdAt DateTime @default(now()) journeySegments JourneySegment[] - @@schema("passenger") } @@ -988,7 +958,6 @@ model JourneySegment { arrivalStationId String journey Journey @relation(fields: [journeyId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - @@schema("passenger") } @@ -1032,7 +1001,6 @@ model Route { fareRules RouteFareRule[] segmentFares SegmentFareRule[] schedules TrainSchedule[] - @@schema("passenger") } @@ -1102,7 +1070,6 @@ model Agent { bookings AgentBooking[] shifts AgentShift[] commissions AgentCommission[] - @@schema("passenger") } @@ -1117,7 +1084,6 @@ model AgentBooking { createdAt DateTime @default(now()) agent Agent @relation(fields: [agentId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id]) - @@schema("passenger") } @@ -1177,7 +1143,6 @@ model BookingCancellation { processedAt DateTime? createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) - @@schema("passenger") } @@ -1205,7 +1170,6 @@ model BaggageAllowance { excessFeePerKg Int currency String @default("ETB") createdAt DateTime @default(now()) - @@schema("passenger") } @@ -1249,7 +1213,6 @@ model NotificationTemplate { bodyTemplate String active Boolean @default(true) createdAt DateTime @default(now()) - @@schema("passenger") } @@ -1288,7 +1251,6 @@ model FraudRule { config Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@schema("passenger") } diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 14907f87e..9e1fffa44 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -212,7 +212,7 @@ async function seedRoute() { create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] }, }); } - + const returnRoute = await prisma.route.upsert({ where: { code: 'Route-102' }, update: {}, @@ -317,7 +317,7 @@ async function seedTrips() { const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); - + const schedules = []; for (let d = 0; d < 5; d++) { @@ -403,7 +403,7 @@ async function seedTrips() { const coachAssignments = []; const liveStatuses = []; - + for (const schedule of createdSchedules) { for (let p = 0; p < coaches.length; p++) { coachAssignments.push({ @@ -418,12 +418,12 @@ async function seedTrips() { 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`); } @@ -453,7 +453,7 @@ async function seedFareRules() { validFrom, }); } - + await Promise.all( fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) ); @@ -518,7 +518,7 @@ async function seedSegmentFares() { include: { stops: { orderBy: { sequence: 'asc' } } }, }); const seatClasses = await prisma.seatClass.findMany(); - const validFrom = new Date('2024-01-01'); + const validFrom = new Date('2026-01-01'); if (route && route.stops.length > 2) { for (const sc of seatClasses) { @@ -531,7 +531,7 @@ async function seedSegmentFares() { baseFareMinor: Math.floor(sc.baseFareMinor * 0.4), validFrom, }, - }).catch(() => {}); + }).catch(() => { }); await prisma.segmentFareRule.create({ data: { @@ -542,7 +542,7 @@ async function seedSegmentFares() { baseFareMinor: Math.floor(sc.baseFareMinor * 0.6), validFrom, }, - }).catch(() => {}); + }).catch(() => { }); } console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`); } @@ -586,16 +586,16 @@ async function seedMenuAndFood() { 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 + }).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 + }).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 + }).catch(() => { }); // ignore if exists } console.log(` ✅ Menu categories and items created`); } @@ -682,6 +682,9 @@ async function main() { const steps: Array<[string, () => Promise]> = [ ['system users', seedSystemUsers], + ['fare rules', seedFareRules], + ['segment fares', seedSegmentFares], + ['currency', seedCurrency] ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 536ddaa3b..5d3a288cd 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -47,6 +47,9 @@ export class FareCalculateDto { @ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' }) @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' }) + @IsOptional() @IsString() scheduleId?: string; } export class FareBreakdownDto { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index b111b5d67..9071f0535 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -32,20 +32,59 @@ export class FareEngineService { s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence, ); - const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined); - if (missingDistance.length > 0) - throw new BadRequestException( - `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`, - ); - - const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); - const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - const ratePerKmMinor = seatClass.baseFareMinor; - const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate + const now = new Date(); + const [originStation, destStation] = await Promise.all([ + this.prisma.station.findUnique({ where: { id: dto.originStationId } }), + this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), + ]); + const segmentRoute = originStation && destStation + ? `${originStation.code}-${destStation.code}` : null; + const fullRoute = `${route.code}`; + + const fareRuleCandidates = await this.prisma.fareRule.findMany({ + where: { + seatClassId: dto.seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }); + + const fareRule = this.pickBestFareRule( + fareRuleCandidates, + dto.scheduleId, + segmentRoute, + fullRoute, + dto.nationality, + ); + + let baseFarePerPassengerMinor: number; + let ratePerKmMinor: number; + let totalDistanceKm: number; + let fareSource: string; + + if (fareRule) { + // Flat fare from FareRule — distance is informational only + baseFarePerPassengerMinor = fareRule.baseFareMinor; + totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); + ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; + fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE'; + } else { + // Distance × rate fallback + const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined); + if (missingDistance.length > 0) + throw new BadRequestException( + `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`, + ); + totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); + ratePerKmMinor = seatClass.baseFareMinor; + baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + fareSource = 'DISTANCE_RATE'; + } // Premium and insurance fees applied per passenger const premiumPerPassenger = seatClass.premiumMinor ?? 0; @@ -84,11 +123,6 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate); - const [originStation, destStation] = await Promise.all([ - this.prisma.station.findUnique({ where: { id: dto.originStationId } }), - this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), - ]); - const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, @@ -110,9 +144,11 @@ export class FareEngineService { `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, + `Fare source: ${fareSource}`, ].join('\n'); return { + fareSource, routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, @@ -161,6 +197,37 @@ export class FareEngineService { return results.filter(Boolean); } + private pickBestFareRule( + candidates: any[], + scheduleId?: string, + segmentRoute?: string | null, + fullRoute?: string, + nationality?: string, + ): any | null { + const nat = nationality ?? null; + const priorities = [ + { tripId: scheduleId, route: segmentRoute, nationality: nat }, + { tripId: scheduleId, route: segmentRoute, nationality: null }, + { tripId: scheduleId, route: fullRoute, nationality: nat }, + { tripId: scheduleId, route: fullRoute, nationality: null }, + { tripId: scheduleId, route: null, nationality: nat }, + { tripId: scheduleId, route: null, nationality: null }, + { tripId: null, route: segmentRoute, nationality: nat }, + { tripId: null, route: segmentRoute, nationality: null }, + { tripId: null, route: fullRoute, nationality: nat }, + { tripId: null, route: fullRoute, nationality: null }, + { tripId: null, route: null, nationality: nat }, + { tripId: null, route: null, nationality: null }, + ]; + for (const p of priorities) { + const match = candidates.find( + c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality, + ); + if (match) return match; + } + return null; + } + async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, @@ -175,6 +242,7 @@ export class FareEngineService { destinationStationId: schedule.destinationStationId, seatClassId, nationality, + scheduleId, }); } @@ -199,6 +267,7 @@ export class FareEngineService { destinationStationId: schedule.destinationStationId, seatClassId: sc.id, nationality, + scheduleId, }).catch(() => null), ), ); diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 8cb8ea253..378bc8fa7 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -12,34 +12,22 @@ export class SchedulesController { @Post('bulk-generate') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Bulk generate repetitive schedules', - description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.', - }) - @ApiResponse({ status: 201, description: 'Schedules generated successfully' }) - @ApiResponse({ status: 400, description: 'Invalid parameters or route not found' }) + @ApiOperation({ summary: 'Bulk generate repetitive schedules' }) bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { return this.service.bulkGenerateSchedules(dto); } @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Create a train schedule from a route template', - description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`, - }) - @ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' }) - @ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' }) - @ApiResponse({ status: 404, description: 'Train or route not found' }) + @ApiOperation({ summary: 'Create a train schedule from a route template' }) createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } @Get() @ApiOperation({ summary: 'List schedules with optional filters' }) - @ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' }) - @ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' }) - @ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' }) - @ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' }) - @ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' }) + @ApiQuery({ name: 'date', required: false }) + @ApiQuery({ name: 'routeId', required: false }) + @ApiQuery({ name: 'trainId', required: false }) + @ApiQuery({ name: 'status', required: false, enum: TripStatus }) listSchedules( @Query('date') date?: string, @Query('routeId') routeId?: string, @@ -57,57 +45,63 @@ export class SchedulesController { @ApiResponse({ status: 201, description: 'Fare rule created' }) createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } + @Patch('fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update a fare rule' }) + @ApiParam({ name: 'id', description: 'FareRule UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule updated' }) + updateFareRule(@Param('id') id: string, @Body() dto: Partial) { + return this.service.updateFareRule(id, dto); + } + + @Delete('fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a fare rule' }) + @ApiParam({ name: 'id', description: 'FareRule UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule deleted' }) + deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); } + @Post('segment-fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' }) - @ApiResponse({ status: 201, description: 'Segment fare rule created' }) + @ApiOperation({ summary: 'Create a segment fare rule' }) createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } @Get('routes/:routeId/segment-fares') @ApiOperation({ summary: 'List all segment fare rules for a route' }) @ApiParam({ name: 'routeId', description: 'Route UUID' }) - @ApiResponse({ status: 200, description: 'List of segment fare rules' }) getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } @Patch('segment-fares/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) - @ApiResponse({ status: 200, description: 'Segment fare rule updated' }) updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } @Delete('segment-fares/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) - @ApiResponse({ status: 200, description: 'Segment fare rule deleted' }) deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } // ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) ===== @Get(':id') - @ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' }) + @ApiOperation({ summary: 'Get schedule detail' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } @Patch(':id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' }) + @ApiOperation({ summary: 'Update a schedule (partial)' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Schedule updated' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { return this.service.updateSchedulePartial(id, dto); } @Patch(':id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' }) + @ApiOperation({ summary: 'Update schedule status' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Status updated' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) { return this.service.updateScheduleStatus(id, dto); } @@ -116,26 +110,18 @@ export class SchedulesController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Schedule deleted' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - deleteSchedule(@Param('id') id: string) { - return this.service.deleteSchedule(id); - } + deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } @Get(':id/stops') - @ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' }) + @ApiOperation({ summary: 'List all stops for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) getStops(@Param('id') id: string) { return this.service.getStops(id); } @Patch(':id/stops/:sequence') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update planned times or live status of a specific stop' }) + @ApiOperation({ summary: 'Update a stop time' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number' }) - @ApiResponse({ status: 200, description: 'Stop updated' }) - @ApiResponse({ status: 404, description: 'Stop not found on schedule' }) updateStop( @Param('id') id: string, @Param('sequence', ParseIntPipe) sequence: number, @@ -145,19 +131,26 @@ export class SchedulesController { @Get(':scheduleId/fares/stored') @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' }) getStoredFares(@Param('scheduleId') scheduleId: string) { return this.service.getFareRules(scheduleId); } - @Get(':scheduleId/fares') - @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) + @Get(':scheduleId/fares/all') + @ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' }) - @ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' }) - @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule or seat class not found' }) + @ApiQuery({ name: 'nationality', required: false }) + getAllFares( + @Param('scheduleId') scheduleId: string, + @Query('nationality') nationality?: string, + ) { + return this.service.getAllFaresFromEngine(scheduleId, nationality); + } + + @Get(':scheduleId/fares') + @ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'seatClassId', required: true }) + @ApiQuery({ name: 'nationality', required: false }) getFare( @Param('scheduleId') scheduleId: string, @Query('seatClassId') seatClassId: string, @@ -166,42 +159,15 @@ export class SchedulesController { return this.service.getFareFromEngine(scheduleId, seatClassId, nationality); } - @Get(':scheduleId/fares/all') - @ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' }) - @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' }) - @ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' }) - @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - getAllFares( - @Param('scheduleId') scheduleId: string, - @Query('nationality') nationality?: string, - ) { - return this.service.getAllFaresFromEngine(scheduleId, nationality); - } - @Post(':id/fares/sync') - @ApiOperation({ - summary: 'Sync fares from fare engine', - description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.', - }) + @ApiOperation({ summary: 'Sync fares from fare engine' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' }) - @ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - syncFares(@Param('id') id: string) { - return this.service.syncFaresFromEngine(id); - } + syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); } @Post(':id/coaches') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Assign coaches to a schedule', - description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.' - }) + @ApiOperation({ summary: 'Assign coaches to a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 201, description: 'Coaches assigned successfully' }) - @ApiResponse({ status: 404, description: 'Schedule or coach not found' }) assignCoaches( @Param('id') id: string, @Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> }, @@ -212,21 +178,14 @@ export class SchedulesController { @Get(':id/coaches') @ApiOperation({ summary: 'Get assigned coaches for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' }) - getAssignedCoaches(@Param('id') id: string) { - return this.service.getAssignedCoaches(id); - } + getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } @Delete(':id/coaches/:coachId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Remove a coach assignment from a schedule' }) + @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'coachId', description: 'Coach UUID' }) - @ApiResponse({ status: 200, description: 'Coach assignment removed' }) - removeCoachAssignment( - @Param('id') id: string, - @Param('coachId') coachId: string, - ) { + removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) { return this.service.removeCoachAssignment(id, coachId); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index fd0776141..0234824d1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -18,7 +18,6 @@ export class SchedulesService { const errors: string[] = []; const scheduleIds: string[] = []; - // Validate route and get stops for plannedTimes generation const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -45,7 +44,6 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // Assign coaches if provided if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, @@ -58,15 +56,10 @@ export class SchedulesService { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); } - // Move to next repetition currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000); } - return { - schedulesCreated: scheduleCount, - errors, - scheduleIds, - }; + return { schedulesCreated: scheduleCount, errors, scheduleIds }; } async listSchedules(dto: ListSchedulesDto) { @@ -104,7 +97,6 @@ export class SchedulesService { const arr = new Date(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); - // Validate route exists and has stops const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -113,21 +105,13 @@ export class SchedulesService { if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); - // Check for duplicate schedule with same train, route, and date const depDate = new Date(dep); depDate.setHours(0, 0, 0, 0); const nextDay = new Date(depDate); nextDay.setDate(nextDay.getDate() + 1); const existingSchedule = await this.prisma.trainSchedule.findFirst({ - where: { - trainId: dto.trainId, - routeId: dto.routeId, - departureAt: { - gte: depDate, - lt: nextDay, - }, - }, + where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } }, }); if (existingSchedule) { @@ -136,7 +120,6 @@ export class SchedulesService { ); } - // Auto-generate plannedTimes if not provided or empty let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { const totalDuration = arr.getTime() - dep.getTime(); @@ -144,7 +127,6 @@ export class SchedulesService { plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -154,7 +136,6 @@ export class SchedulesService { const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); stopTime = new Date(dep.getTime() + totalDuration * progress); } - return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), @@ -163,14 +144,12 @@ export class SchedulesService { }); } - // Validate all route stop sequences are covered by plannedTimes const providedSeqs = new Set(plannedTimes.map(t => t.sequence)); const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq)); if (missingSeqs.length > 0) { throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`); } - // Derive origin and destination from first and last route stop const firstStop = route.stops[0]; const lastStop = route.stops[route.stops.length - 1]; @@ -188,9 +167,7 @@ export class SchedulesService { include: { train: true, originStation: true, destinationStation: true }, }); - const plannedTimesMap = Object.fromEntries( - plannedTimes.map(t => [t.sequence, t]), - ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); return this.getSchedule(schedule.id); @@ -230,10 +207,7 @@ export class SchedulesService { }; } - private async resolveEffectiveStatuses( - scheduleId: string, - seatIds: string[], - ): Promise> { + private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise> { const statusMap = new Map(); if (seatIds.length === 0) return statusMap; @@ -304,7 +278,6 @@ export class SchedulesService { plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -314,7 +287,6 @@ export class SchedulesService { const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); stopTime = new Date(dep.getTime() + totalDuration * progress); } - return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), @@ -323,9 +295,7 @@ export class SchedulesService { }); } - const plannedTimesMap = Object.fromEntries( - plannedTimes.map(t => [t.sequence, t]), - ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap); return this.getSchedule(id); @@ -376,9 +346,35 @@ export class SchedulesService { validFrom: new Date(validFrom), validUntil: validUntil ? new Date(validUntil) : null, }, + include: { seatClass: true }, }); } + async updateFareRule(id: string, dto: Partial) { + const existing = await this.prisma.fareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Fare rule not found'); + + const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; + return this.prisma.fareRule.update({ + where: { id }, + data: { + ...rest, + ...(scheduleId !== undefined && { tripId: scheduleId }), + ...(nationality !== undefined && { nationality }), + ...(validFrom && { validFrom: new Date(validFrom) }), + ...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }), + }, + include: { seatClass: true }, + }); + } + + async deleteFareRule(id: string) { + const existing = await this.prisma.fareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Fare rule not found'); + await this.prisma.fareRule.delete({ where: { id } }); + return { deleted: true, id }; + } + createSegmentFareRule(dto: any) { const { validFrom, validUntil, passengerCategory, ...rest } = dto; return this.prisma.segmentFareRule.create({ @@ -419,7 +415,6 @@ export class SchedulesService { async getFareRules(scheduleId?: string) { const where: any = {}; if (scheduleId) where.tripId = scheduleId; - return this.prisma.fareRule.findMany({ where, include: { seatClass: true }, @@ -439,11 +434,10 @@ export class SchedulesService { }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route'); - return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); } catch (error) { throw new BadRequestException( - error instanceof Error ? error.message : 'Failed to calculate fares for schedule' + error instanceof Error ? error.message : 'Failed to calculate fares for schedule', ); } } @@ -483,20 +477,13 @@ export class SchedulesService { return { synced, errors }; } - async assignCoaches( - scheduleId: string, - coaches: Array<{ coachId: string; positionNumber: number }>, - ) { + async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); const coachIds = coaches.map(c => c.coachId); - const existingCoaches = await this.prisma.coach.findMany({ - where: { id: { in: coachIds } }, - }); - if (existingCoaches.length !== coachIds.length) { - throw new NotFoundException('One or more coaches not found'); - } + const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); + if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); @@ -508,20 +495,13 @@ export class SchedulesService { })); await this.prisma.coachAssignment.createMany({ data }); - return { message: 'Coaches assigned successfully', count: coaches.length }; } async getAssignedCoaches(scheduleId: string) { return this.prisma.coachAssignment.findMany({ where: { scheduleId }, - include: { - coach: { - include: { - seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, - }, - }, - }, + include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, orderBy: { positionNumber: 'asc' }, }); } @@ -535,30 +515,22 @@ export class SchedulesService { if (dto.departureAt || dto.arrivalAt) { const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt); const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt); - if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); - updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); } - if (dto.status) { - updateData.status = dto.status; - } + if (dto.status) updateData.status = dto.status; if (Object.keys(updateData).length > 0) { - await this.prisma.trainSchedule.update({ - where: { id }, - data: updateData, - }); + await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); } else { - // Remove all coach assignments when empty array is sent await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); } } @@ -567,12 +539,9 @@ export class SchedulesService { } async removeCoachAssignment(scheduleId: string, coachId: string) { - const assignment = await this.prisma.coachAssignment.findFirst({ - where: { scheduleId, coachId }, - }); + const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } }); if (!assignment) throw new NotFoundException('Coach assignment not found'); - await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); return { message: 'Coach assignment removed' }; } -} \ No newline at end of file +} diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9646c20ea..0ca9a8723 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -441,6 +441,7 @@ export class SearchService { destinationStationId, seatClassId: sc.id, nationality, + scheduleId: schedule.id, }); return { seatClassName: fare.seatClassName, diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index d57a67c83..8de163da9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,14 +2,13 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react'; +import { Edit, Loader2, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; -import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; -interface Currency { +interface CurrencyRate { id: string; code: string; name: string; @@ -18,203 +17,104 @@ interface Currency { exchangeRate: number; isActive: boolean; createdAt: string; - updatedAt: string; } +const CURRENCY_META: Record = { + ETB: { name: 'Ethiopian Birr', symbol: 'Br' }, + DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' }, + USD: { name: 'US Dollar', symbol: '$' }, +}; + export default function CurrenciesPage() { - const [showModal, setShowModal] = useState(false); - const [editingCurrency, setEditingCurrency] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({ - isOpen: false, - id: null, - }); + const [editingRate, setEditingRate] = useState(null); + const [rateInput, setRateInput] = useState(''); const [error, setError] = useState(null); const queryClient = useQueryClient(); - const [currencyForm, setCurrencyForm] = useState({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - - const { data: currencies = [], isLoading } = useQuery({ + const { data: currencies = [], isLoading } = useQuery({ queryKey: ['currencies'], queryFn: () => apiClient.get('/currencies'), }); - const createMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/currencies', data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - resetForm(); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create currency'); - }, - }); - const updateMutation = useMutation({ - mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data), + mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) => + apiClient.patch(`/currencies/${id}`, { exchangeRate }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setEditingCurrency(null); - resetForm(); + setEditingRate(null); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update currency'); + setError(err.response?.data?.message || 'Failed to update exchange rate'); }, }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setDeleteConfirm({ isOpen: false, id: null }); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to delete currency'); - }, - }); - - const syncRatesMutation = useMutation({ + const syncMutation = useMutation({ mutationFn: () => apiClient.post('/currencies/sync-rates', {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to sync exchange rates'); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), + onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'), }); - const resetForm = () => { - setCurrencyForm({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - setEditingCurrency(null); - setShowModal(false); + const handleEdit = (currency: CurrencyRate) => { + setEditingRate(currency); + setRateInput(currency.exchangeRate.toString()); setError(null); }; - const handleEditCurrency = (currency: Currency) => { - setEditingCurrency(currency); - setCurrencyForm({ - code: currency.code, - name: currency.name, - symbol: currency.symbol, - baseCurrencyCode: currency.baseCurrencyCode, - exchangeRate: currency.exchangeRate.toString(), - }); - setError(null); - setShowModal(true); - }; - - const handleSaveCurrency = async () => { - setError(null); - if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) { - setError('All fields are required'); - return; - } - - const rate = parseFloat(currencyForm.exchangeRate); + const handleSave = async () => { + const rate = parseFloat(rateInput); if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; } - - const payload = { - code: currencyForm.code.toUpperCase(), - name: currencyForm.name, - symbol: currencyForm.symbol, - baseCurrencyCode: currencyForm.baseCurrencyCode, - exchangeRate: rate, - }; - - if (editingCurrency) { - await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload }); - } else { - await createMutation.mutateAsync(payload); - } + await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate }); }; - const confirmDelete = async () => { - if (deleteConfirm.id) { - await deleteMutation.mutateAsync(deleteConfirm.id); - } - }; - - const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || []; + const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? []; const columns = [ { key: 'code', - label: 'Code', - render: (currency: Currency) => ( - {currency.code} - ), - }, - { - key: 'name', - label: 'Name', - render: (currency: Currency) => ( - {currency.name} - ), - }, - { - key: 'symbol', - label: 'Symbol', - render: (currency: Currency) => ( - {currency.symbol} - ), - }, - { - key: 'baseCurrencyCode', - label: 'Base Currency', - render: (currency: Currency) => ( - {currency.baseCurrencyCode} - ), - }, - { - key: 'exchangeRate', - label: 'Exchange Rate', - render: (currency: Currency) => ( -
-
- 1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code} -
-
- 1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode} + label: 'Currency', + render: (c: CurrencyRate) => ( +
+ + {CURRENCY_META[c.code]?.symbol ?? c.symbol} + +
+
{c.code}
+
{CURRENCY_META[c.code]?.name ?? c.name}
), }, { - key: 'isActive', - label: 'Status', - render: (currency: Currency) => ( - - {currency.isActive ? 'Active' : 'Inactive'} - + key: 'baseCurrencyCode', + label: 'Base', + render: (c: CurrencyRate) => ( + {c.baseCurrencyCode} ), }, { - key: 'updatedAt', + key: 'exchangeRate', + label: 'Exchange Rate', + render: (c: CurrencyRate) => ( +
+
+ 1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code} +
+
+ 1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode} +
+
+ ), + }, + { + key: 'createdAt', label: 'Last Updated', - render: (currency: Currency) => ( + render: (c: CurrencyRate) => ( - {new Date(currency.updatedAt).toLocaleDateString()} + {new Date(c.createdAt).toLocaleDateString()} ), }, @@ -222,140 +122,93 @@ export default function CurrenciesPage() { const actions = [ { - label: 'Edit', - onClick: handleEditCurrency, + label: 'Edit Rate', + onClick: handleEdit, variant: 'secondary' as const, icon: Edit, }, - { - label: 'Delete', - onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }), - variant: 'danger' as const, - icon: Trash2, - }, ]; return (
-

Currencies

-

Manage exchange rates and display currencies

-
-
- syncRatesMutation.mutate()} - loading={syncRatesMutation.isPending} - > - Sync Rates - - { - setError(null); - setEditingCurrency(null); - setCurrencyForm({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - setShowModal(true); - }} - > - Add Currency - +

Exchange Rates

+

+ Manage ETB exchange rates for display currencies (DJF, USD) +

+ syncMutation.mutate()} + loading={syncMutation.isPending} + > + Sync Rates +
+ {error && !editingRate && ( +
+ {error} +
+ )} +
-
-
-
-
Total Currencies
-
- {currenciesArray.length} +
+ {(['ETB', 'DJF', 'USD'] as const).map((code) => { + const entry = currenciesArray.find((c: CurrencyRate) => c.code === code); + return ( +
+
+
{CURRENCY_META[code].name}
+
{code}
+
+
+ {entry ? ( + <> +
{entry.exchangeRate}
+
per ETB
+ + ) : ( + Not configured + )} +
-
-
-
Active
-
- {currenciesArray.filter((c: Currency) => c.isActive).length} -
-
-
-
Base Currency
-
ETB
-
-
-
Last Sync
-
- {currenciesArray.length > 0 - ? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString() - : 'N/A'} -
-
-
- - {isLoading ? ( -
- -
- ) : currenciesArray.length === 0 ? ( -
-

No currencies configured. Click "Add Currency" to create one.

-
- ) : ( - - )} + ); + })}
+ + {isLoading ? ( +
+ +
+ ) : ( + + )}
-
-

Currency Management

-
    -
  • - • Base Currency: All exchange rates are calculated relative to this currency (typically ETB) -
  • -
  • - • Exchange Rate: How many units of the currency equal 1 unit of the base currency -
  • -
  • - • Display Currencies: Configure which currencies customers can view prices in -
  • -
  • - • Sync Rates: Automatically update exchange rates from external sources -
  • -
+
+

How it works

+

• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)

+

• DJF and USD rates are used to display prices to passengers in their preferred currency

+

• Rates apply globally; changes take effect immediately on the next booking or fare quote

- {/* Delete Confirmation */} - setDeleteConfirm({ isOpen: false, id: null })} - onConfirm={confirmDelete} - title="Delete Currency" - message="Are you sure you want to delete this currency? This action cannot be undone." - confirmText="Delete" - isDanger={true} - warning="This will remove the currency from the system." - /> - - {/* Add/Edit Modal */} { setEditingRate(null); setError(null); }} + title={`Update Rate — ${editingRate?.code}`} + size="sm" >
{error && ( @@ -364,108 +217,38 @@ export default function CurrenciesPage() {
)} -
-
- - setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })} - className="input w-full" - placeholder="e.g., USD" - maxLength={3} - disabled={!!editingCurrency} - required - /> -

3-letter ISO code (e.g., USD, DJF, GBP)

-
- -
- - setCurrencyForm({ ...currencyForm, name: e.target.value })} - className="input w-full" - placeholder="e.g., United States Dollar" - required - /> -
-
- -
-
- - setCurrencyForm({ ...currencyForm, symbol: e.target.value })} - className="input w-full" - placeholder="e.g., $" - maxLength={3} - required - /> -
- -
- - -

All rates relative to this currency

-
+
+ Currency: + {editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}
- -
- setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })} - className="input w-full" - placeholder="e.g., 0.018" - required - /> -
- 1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code} -
-
- {currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && ( -

- ≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode} + + setRateInput(e.target.value)} + className="input w-full" + placeholder="e.g., 3.25" + autoFocus + /> + {rateInput && parseFloat(rateInput) > 0 && ( +

+ ≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}

)}
-
-

Exchange Rate Example:

-

If 1 ETB = 0.018 USD, enter 0.018

-

If 1 ETB = 3.25 DJF, enter 3.25

-
- -
- +
+ { setEditingRate(null); setError(null); }}> Cancel - - {editingCurrency ? 'Update Currency' : 'Add Currency'} + + Save Rate
diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index a2dcae6aa..19fd44620 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -731,14 +731,19 @@ export default function PricingPage() {
- setFareForm({ ...fareForm, route: e.target.value })} className="input w-full" - placeholder="e.g., ADD-DJI" - /> -

e.g., ADD-DJI for full route

+ > + + {routesArray.map((route: Route) => ( + + ))} + +

Scope this fare to a specific route

From e1324719011302081e760eb45c6437d8efe9d773 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Thu, 18 Jun 2026 16:26:35 +0300 Subject: [PATCH 03/15] Update docker-compose.yaml --- docker-compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 3687f592a..09eb8728f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,6 +20,8 @@ services: - "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}" env_file: - apps/edr-freight-api/.env + extra_hosts: + - "paymentcallback.triaplc.com:10.18.7.179" passenger-api: build: From 804d67ecc5e9b71167e63f1a9d7eeb221d461857 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 18 Jun 2026 22:03:12 +0300 Subject: [PATCH 04/15] Update seat map and sms payload --- .../src/modules/notifications/dtos/sms.dto.ts | 2 +- .../notifications/notifications.service.ts | 2 +- .../src/modules/search/search.service.ts | 7 +- .../src/modules/tickets/tickets.service.ts | 6 +- .../portal/src/app/booking/results/page.tsx | 248 ++++++++----- .../portal/src/app/booking/review/page.tsx | 8 +- .../portal/src/app/booking/seats/page.tsx | 338 ++++++++++++------ .../portal/src/types/index.ts | 9 + 8 files changed, 419 insertions(+), 201 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts index e50cf6c64..ea91f89a2 100644 --- a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts @@ -34,7 +34,7 @@ export class SingleMessageDto { }) @IsString() @IsNotEmpty() - sms: string; + message: string; } export class BulkMessagesDto { diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 8960f1a87..595633e93 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -21,7 +21,7 @@ export class NotificationsService { ) { this.channels = new Map([ ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], - ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }], + ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['PUSH', this.pushAdapter as NotificationChannel], ]); } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 0ca9a8723..9087ee7ac 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -500,11 +500,12 @@ export class SearchService { coachTypeId: string; coachTypeName: string; coachTypeCode: string; + coachId: string; classes: Array<{ name: string; baseFareMinor: number }>; }>> { const coachTypeMap = new Map< string, - { coachType: any; classNames: Set } + { coachType: any; classNames: Set; coachId: string } >(); for (const assignment of schedule.coachAssignments) { @@ -515,6 +516,7 @@ export class SearchService { coachTypeMap.set(coachType.id, { coachType, classNames: new Set(), + coachId: assignment.coach.id, }); } @@ -523,7 +525,7 @@ export class SearchService { } const result = []; - for (const [, { coachType, classNames }] of coachTypeMap) { + for (const [, { coachType, classNames, coachId }] of coachTypeMap) { const classes = Array.from(classNames) .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); @@ -537,6 +539,7 @@ export class SearchService { coachTypeId: coachType.id, coachTypeName: coachType.name, coachTypeCode: coachType.code, + coachId, classes, }); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 2c57a59e3..4cf4ce9d3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -281,7 +281,7 @@ export class TicketsService { if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') { throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2'); } - const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[]; const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); @@ -333,7 +333,7 @@ export class TicketsService { if (!validLegs.includes(resolvedLeg)) { throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`); } - const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[]; if (logs.some(l => l.leg === resolvedLeg)) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); @@ -435,7 +435,7 @@ export class TicketsService { booking.bookingType === 'TRANSIT' || booking.bookingType === 'ROUND_TRIP_TRANSIT'; if (isMultiLeg && offlineLeg) { - const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[]; if (existingLogs.some(l => l.leg === offlineLeg)) { results.duplicate++; continue; diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 2e8d9e950..1e906be2c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train } from 'lucide-react'; +import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; @@ -13,7 +13,7 @@ export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); - const [selectedClasses, setSelectedClasses] = useState>({}); + const [selectedCoachTypes, setSelectedCoachTypes] = useState>({}); const [outboundScheduleData, setOutboundScheduleData] = useState(null); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); @@ -142,27 +142,22 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectClass = (scheduleId: string, seatClass: string) => { - setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass })); + const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; - const selectedClass = selectedClasses[scheduleId]; + const selectedCoachType = selectedCoachTypes[scheduleId]; - if (!selectedClass) { - alert('Please select a seat class before continuing'); + if (!selectedCoachType) { + alert('Please select a coach type before continuing'); return; } - const selectedClassFare = schedule.faresByClass?.find( - (f: any) => f.seatClassName === selectedClass - ); - - if (!selectedClassFare) { - alert('Unable to find fare for selected class'); - return; - } + // Find the coach type to get pricing info + const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id); + const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -176,10 +171,13 @@ export default function ResultsPage() { departureTime: schedule.departureAt || schedule.departureTime || '', arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '', duration: durationStr, - baseFareAdult: selectedClassFare.baseFareMinor, - baseFareChild: selectedClassFare.baseFareMinor, - selectedSeatClass: selectedClass, - selectedSeatClassName: selectedClass, + baseFareAdult: minFare, + baseFareChild: minFare, + selectedSeatClass: selectedCoachType.name, + selectedSeatClassName: selectedCoachType.name, + selectedCoachId: selectedCoachType.id, + selectedCoachTypeCode: selectedCoachType.code, + selectedCoachTypeName: selectedCoachType.name, }; // For round trip, store outbound and wait for inbound selection @@ -211,10 +209,16 @@ export default function ResultsPage() { const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; - const selectedClass = selectedClasses[scheduleId]; - const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 - ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)) - : null; + const selectedCoachType = selectedCoachTypes[scheduleId]; + + // Calculate lowest fare from coach types + let lowestFare = null; + if (schedule.coachTypes?.length) { + const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0); + lowestFare = allFares.length ? Math.min(...allFares) : null; + } else if (schedule.faresByClass?.length) { + lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)); + } const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; @@ -284,16 +288,16 @@ export default function ResultsPage() { {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
- {selectedClass && ( + {selectedCoachType && (

- {selectedClass.replace(/_/g, ' ')} selected + {selectedCoachType.name} selected

)}
@@ -488,95 +492,177 @@ export default function ResultsPage() { {classModal && (() => { const scheduleId = classModal.scheduleId || classModal.id || ''; - const selectedClass = selectedClasses[scheduleId]; + const selectedCoachType = selectedCoachTypes[scheduleId]; const isOutbound = (classModal as any).isOutbound; + const coachTypes = classModal.coachTypes || []; + + const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes('soft') || lower.includes('vip')) return Star; + if (lower.includes('bed')) return Bed; + return Armchair; + }; + return ( <>
setClassModal(null)} /> -
-
+
-

Select Class

-

- - {classModal.trainNumber} · {classModal.origin?.name} → {classModal.destination?.name} +

Choose Your Coach

+

+ + {classModal.trainNumber} + · + {classModal.origin?.name} → {classModal.destination?.name}

-
- {classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? ( -
- {classModal.faresByClass.map((fareClass: any) => { - const isSelected = selectedClass === fareClass.seatClassName; - const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0; - const isAvailable = availableSeats > 0; - const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed'); +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = selectedCoachType?.id === coachType.coachId; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const CoachIcon = getCoachIcon(coachType.coachTypeName); + return ( ); })}
) : ( -

No seat classes available

+
+
+ +
+

No coach types available for this journey

+
)}
-
- - {!selectedClass && ( -

Please select a class to continue

- )} +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

+ )} +
- + ); })()} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 6b09ec422..0ce5a340f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -285,7 +285,7 @@ export default function ReviewPage() { bookingData = { passengerId: passengerId, scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, - holdId: seatHold.holdId, + holdId: seatHold?.holdId || '', originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, @@ -312,7 +312,7 @@ export default function ReviewPage() { bookingData.returnScheduleId = inboundSchedule.id; bookingData.returnOriginStationId = searchCriteria.destinationStationId; bookingData.returnDestinationStationId = searchCriteria.originStationId; - bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed bookingData.returnSeatClassId = returnSeatClassId; } @@ -324,7 +324,7 @@ export default function ReviewPage() { // For guests: send full passenger details array bookingData = { scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, - holdId: seatHold.holdId, + holdId: seatHold?.holdId || '', originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, @@ -356,7 +356,7 @@ export default function ReviewPage() { bookingData.returnScheduleId = inboundSchedule.id; bookingData.returnOriginStationId = searchCriteria.destinationStationId; bookingData.returnDestinationStationId = searchCriteria.originStationId; - bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed bookingData.returnSeatClassId = returnSeatClassId; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index d3c39a32e..3cd9db800 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -60,11 +60,35 @@ export default function SeatsPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule); + const coachId = (currentSchedule as any)?.selectedCoachId; + const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode; const { data: seatMapData, isLoading, error } = useQuery({ - queryKey: ['seatmap', currentSchedule?.id, currentJourneyType], - queryFn: () => apiClient.get(`/seats/seatmap/${currentSchedule?.id}`), - enabled: !!currentSchedule?.id, + queryKey: ['seatmap', currentSchedule?.id, coachId, currentJourneyType], + queryFn: async () => { + const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachId=${coachId}`; + console.log('🪑 Seatmap Request:', { + endpoint, + scheduleId: currentSchedule?.id, + coachId, + coachTypeCode, + currentJourneyType, + }); + + const response = await apiClient.get(endpoint); + + console.log('✅ Seatmap Response:', { + endpoint, + fullResponse: response, + dataCoaches: (response as any)?.data?.coaches?.length || 0, + rootCoaches: (response as any)?.coaches?.length || 0, + }); + + const finalData = (response as any)?.data || response; + console.log('🎯 Final data structure:', finalData); + return finalData; + }, + enabled: !!currentSchedule?.id && !!coachId, }); const holdMutation = useMutation({ @@ -106,22 +130,40 @@ export default function SeatsPage() { }, }); - const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]); + const coaches = useMemo(() => { + const rawCoaches = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || []; + console.log('📦 Raw coaches data:', { + fromRoot: (seatMapData as any)?.coaches?.length || 0, + fromData: (seatMapData as any)?.data?.coaches?.length || 0, + using: rawCoaches.length, + seatMapData + }); + return rawCoaches; + }, [seatMapData]); const filteredCoaches = useMemo(() => { + console.log('🔍 Filtering coaches:', { + totalCoaches: coaches.length, + selectedSeatClass: currentSchedule?.selectedSeatClass, + coachesData: coaches.map((c: any) => ({ + id: c.id, + name: c.name, + label: c.label, + seatClass: c.seatClass, + seatClasses: c.seatClasses, + seatsCount: c.seats?.length || 0 + })) + }); + + const coachesWithSeats = coaches.filter((c: any) => c.seats && c.seats.length > 0); + if (!currentSchedule?.selectedSeatClass) { - return coaches.filter((c: any) => c.seats && c.seats.length > 0); + console.log('✅ No filter applied, returning all coaches:', coachesWithSeats.length); + return coachesWithSeats; } - let filtered = coaches.filter((c: any) => { - const seatClasses = c.seatClasses || [c.seatClass] || []; - return seatClasses.some((seatClassName: string) => - seatClassName === currentSchedule.selectedSeatClass || - seatClassName.replace(/_/g, ' ').toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase() || - seatClassName.toLowerCase() === currentSchedule.selectedSeatClass?.toLowerCase() - ); - }); - return filtered.filter((c: any) => c.seats && c.seats.length > 0); + console.log('✅ No seat class filter - returning all coaches with seats:', coachesWithSeats.length); + return coachesWithSeats; }, [coaches, currentSchedule?.selectedSeatClass]); useEffect(() => { @@ -142,7 +184,10 @@ export default function SeatsPage() { }; const validSeats = useMemo(() => { - let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + let seats = allSeats.filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ''; + return seatLabel && !seatLabel.startsWith('-'); + }); const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); if (isBedCoach && currentSchedule?.selectedSeatClass) { @@ -288,10 +333,22 @@ export default function SeatsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookingId]); - const parseSeatArrangement = (arrangement: string | null): number[] => { + const parseSeatArrangement = (arrangement: string | null, seatClasses?: string[]): number[] => { if (!arrangement) return [2, 2]; - const parts = arrangement.split('+').map(p => parseInt(p.trim())); - return parts.length === 2 ? parts : [2, 2]; + + // Check if this is a bed coach based on seat classes + const isBedCoach = seatClasses?.some(sc => sc?.toLowerCase().includes('bed')); + + if (isBedCoach) { + // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically + // We want to render them as single column, so return [1] + const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0); + return parts.length > 0 ? [Math.max(...parts)] : [3]; + } + + // For regular seats, parse normally (e.g., "3+2" -> [3, 2]) + const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0); + return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; }; const getBedLabel = (bedPosition: string | null): string => { @@ -302,8 +359,7 @@ export default function SeatsPage() { }; const renderCoachSeats = (coach: any, isBedCoach: boolean) => { - const arrangement = parseSeatArrangement(coach.seatArrangement); - const leftCount = arrangement[0]; + const arrangement = parseSeatArrangement(coach.seatArrangement, coach.seatClasses || [coach.seatClass]); if (validSeats.length === 0) { return
No seats
; @@ -312,36 +368,91 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || ''); + // Bed coach with bed positions (Upper, Middle, Lower) if (isBedCoach && hasBedPositionData) { + // Group by the base seat number (column), not by row + // For beds, seats with same number but different positions should be grouped together + const seatGroups = new Map(); + + for (const seat of validSeats) { + const baseNumber = seat.seatNumber || seat.number || seat.label || ''; + if (!seatGroups.has(baseNumber)) { + seatGroups.set(baseNumber, []); + } + seatGroups.get(baseNumber)!.push(seat); + } + + // Sort groups by seat number + const sortedGroups = Array.from(seatGroups.entries()) + .sort(([a], [b]) => { + const numA = parseInt(a) || 0; + const numB = parseInt(b) || 0; + return numA - numB; + }); + return ( -
- {validSeats.map((seat: any) => { - const rowNumber = seat.row || 1; - const shouldFlipIcon = rowNumber % 2 === 0; +
+ {sortedGroups.map(([seatNumber, beds], idx) => { + const shouldFlipIcon = idx % 2 === 0; + + // Order: lower, middle, upper (bottom to top) + const orderedBeds = ['lower', 'middle', 'upper'] + .map(pos => beds.find(seat => seat.bedPosition === pos)) + .filter(seat => seat !== undefined); + + if (orderedBeds.length === 0) return null; return ( -
- {shouldFlipIcon && ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+
+ {shouldFlipIcon && ( +
+ {orderedBeds.map((seat: any) => { + const seatLabel = seat.seatNumber || seat.number || seat.label || ''; + const bedLabelFull = seat.bedPosition ? ( + seat.bedPosition === 'upper' ? 'Upper' : + seat.bedPosition === 'middle' ? 'Middle' : 'Lower' + ) : ''; + return ( +
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''} +
+ ); + })} +
+ )} + +
+ {orderedBeds.map((seat: any) => ( + + ))}
- )} -
- + + {!shouldFlipIcon && ( +
+ {orderedBeds.map((seat: any) => { + const seatLabel = seat.seatNumber || seat.number || seat.label || ''; + const bedLabelFull = seat.bedPosition ? ( + seat.bedPosition === 'upper' ? 'Upper' : + seat.bedPosition === 'middle' ? 'Middle' : 'Lower' + ) : ''; + return ( +
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''} +
+ ); + })} +
+ )}
- {!shouldFlipIcon && ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- )}
); })} @@ -349,68 +460,64 @@ export default function SeatsPage() { ); } - const rows = []; - const processedRows = new Set(); + // Regular seats with row/column arrangement + const rowMap = new Map(); for (const seat of validSeats) { - if (!processedRows.has(seat.row)) { - rows.push(validSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => { - const colA = a.col.charCodeAt(0); - const colB = b.col.charCodeAt(0); - return colA - colB; - })); - processedRows.add(seat.row); + if (!rowMap.has(seat.row)) { + rowMap.set(seat.row, []); } + rowMap.get(seat.row)!.push(seat); } + const rows = Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); + return (
{rows.map((rowSeats: any[], rowIdx: number) => { - const leftSeats = rowSeats.slice(0, leftCount); - const rightSeats = rowSeats.slice(leftCount); + const groups: any[][] = []; + + // Split seats into groups based on arrangement + if (arrangement.length === 1) { + // Single group (all seats together) + groups.push(rowSeats); + } else { + // Multiple groups with aisle separation + arrangement.forEach((_groupSize, groupIdx) => { + const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0); + const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0); + const currentGroup = rowSeats.slice(startIdx, endIdx); + if (currentGroup.length > 0) groups.push(currentGroup); + }); + } + const rowNumber = rowSeats[0]?.row || 1; const shouldFlipArmchair = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return ( -
+
{shouldFlipArmchair && ( -
-
- {leftSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''} -
- ))} -
- {rightSeats.length > 0 &&
} - {rightSeats.length > 0 && ( -
- {rightSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''} -
- ))} +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => { + const seatLabel = seat.label || seat.number || seat.seatNumber || ''; + return ( +
+ {seatLabel} +
+ ); + })}
- )} -
- )} -
-
- {leftSeats.map((seat: any) => ( - ))}
- {rightSeats.length > 0 &&
} - {rightSeats.length > 0 && ( -
- {rightSeats.map((seat: any) => ( + )} +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( ))}
- )} + ))}
{!shouldFlipArmchair && ( -
-
- {leftSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''} -
- ))} -
- {rightSeats.length > 0 &&
} - {rightSeats.length > 0 && ( -
- {rightSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''} -
- ))} +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => { + const seatLabel = seat.label || seat.number || seat.seatNumber || ''; + return ( +
+ {seatLabel} +
+ ); + })}
- )} + ))}
)} - {showSpacing &&
} + {showSpacing &&
}
); })} @@ -456,6 +559,23 @@ export default function SeatsPage() { if (!selectedSchedule || !passengers.length) return null; + if (!coachId) { + return ( +
+
+

No coach selected

+

Please go back and select a coach type

+ +
+
+ ); + } + const allSelected = selectedSeats.length === passengers.length; const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index 532bef8d5..08203494a 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -39,6 +39,15 @@ export interface Schedule { availableSeats?: number; availabilityByClass?: Record; // API returns this faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this + coachTypes?: Array<{ + coachId: string; + coachTypeName: string; + coachTypeCode: string; + classes: Array<{ + name: string; + baseFareMinor: number; + }>; + }>; serviceClass?: string; status?: string; hasAvailability?: boolean; From 8264df133fc1e71647d371c44eff3d45d5d9658b Mon Sep 17 00:00:00 2001 From: marshal Date: Fri, 19 Jun 2026 00:42:27 +0300 Subject: [PATCH 05/15] Redesign payment verification UI with Mantine components and fix route direction logic - Refactor CheckPaymentPage and PaymentFailurePage to use Mantine UI components with modern card-based layouts - Add gradient headers, themed icons, and improved visual hierarchy for payment status states - Implement PaymentCard wrapper component for consistent styling across payment pages - Update route direction logic to trim country values and simplify Djibouti import/export detection - Replace shadcn/ui components with M --- ...4000000001-SeedWagonsWithYardAssignment.ts | 156 +++++++ .../pages/bookings/new-booking-form/schema.ts | 18 +- .../src/pages/payments/CheckPaymentPage.tsx | 407 ++++++++++++++---- .../src/pages/payments/PaymentFailurePage.tsx | 186 ++++++-- .../src/pages/payments/PaymentSuccessPage.tsx | 183 ++++++-- 5 files changed, 800 insertions(+), 150 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts diff --git a/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts new file mode 100644 index 000000000..a615fe9a4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Full wagon re-seed — runs in this order: + * + * 1. DELETE all existing wagons (hard delete, not soft). + * 2. UPSERT all 10 standard wagon types so they are guaranteed to exist. + * 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across + * the 5 main operational yards (10 wagons per yard per type): + * + * KALITY — Kality Rail Terminal + * MOJO — Mojo Dry Port + * DIRE_DAWA — Dire Dawa Yard + * DJIB_PORT — Djibouti Port Terminal + * NAGAD — Nagad Terminal, Djibouti + * + * Wagon numbers follow the pattern -NNNN (e.g. NW5-0001 … NW5-0050). + * Yard IDs are fetched live from freight.yards so the migration is safe across + * all environments regardless of UUID values. + */ +export class SeedWagonsWithYardAssignment1784000000001 + implements MigrationInterface +{ + name = 'SeedWagonsWithYardAssignment1784000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── STEP 1: Remove all wagons ────────────────────────────────────────── + await queryRunner.query(`DELETE FROM freight.wagons;`); + + // ── STEP 2: Ensure all 10 wagon types exist ──────────────────────────── + await queryRunner.query(` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active, + tare_weight_tons + ) + VALUES + ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0), + ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0), + ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0), + ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0), + ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0), + ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0), + ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0), + ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0), + ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0), + ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + tare_weight_tons = EXCLUDED.tare_weight_tons, + deleted_at = NULL, + updated_at = now(); + `); + + // ── STEP 3: Seed 50 wagons per type across 5 yards ──────────────────── + await queryRunner.query(` + DO $$ + DECLARE + wt RECORD; + yard_kality UUID; + yard_mojo UUID; + yard_dire_dawa UUID; + yard_djib_port UUID; + yard_nagad UUID; + yards UUID[]; + i INT; + yard_id UUID; + wagon_num TEXT; + v_tare NUMERIC; + v_payload NUMERIC; + BEGIN + -- Fetch yard IDs by code (safe across envs — UUIDs differ per DB) + SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1; + SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1; + SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1; + SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1; + SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1; + + IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL + OR yard_djib_port IS NULL OR yard_nagad IS NULL + THEN + RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.'; + END IF; + + yards := ARRAY[ + yard_kality, + yard_mojo, + yard_dire_dawa, + yard_djib_port, + yard_nagad + ]; + + FOR wt IN + SELECT id, code, capacity_tons, tare_weight_tons + FROM freight.wagon_types + WHERE is_active = true + ORDER BY code + LOOP + v_tare := COALESCE(wt.tare_weight_tons, 20.0); + v_payload := COALESCE(wt.capacity_tons, 60.0); + + FOR i IN 1 .. 50 LOOP + wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0'); + yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K … + + INSERT INTO freight.wagons ( + id, + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id, + created_at, + updated_at + ) + VALUES ( + uuid_generate_v4(), + wagon_num, + wt.id, + v_tare, + v_payload, + 'Available', + yard_id, + NULL, NULL, NULL, NULL, NULL, + now(), now() + ) + ON CONFLICT (wagon_number) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code; + END LOOP; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove all seeded wagons (full wipe — mirrors what up() did) + await queryRunner.query(`DELETE FROM freight.wagons;`); + } +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index ea4b1214a..118db50c1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -293,17 +293,17 @@ export function getRouteDirection( dest: Freight.BookingReferenceYard | null | undefined, ): Freight.ScheduleTradeDirection | null { if (!origin || !dest) return null; - if (origin.country === "Ethiopia" && dest.country === "Ethiopia") { - return "DOMESTIC"; - } - if (origin.country === "Ethiopia" && dest.country === "Djibouti") { - return "EXPORT"; - } - if (origin.country === "Djibouti" && dest.country === "Ethiopia") { + + const originCountry = origin.country?.trim(); + const destCountry = dest.country?.trim(); + + if (originCountry === "Djibouti") { return "IMPORT"; } - - return null; + if (destCountry === "Djibouti" && originCountry !== "Djibouti") { + return "EXPORT"; + } + return "DOMESTIC"; } export function calcWagons(containers: ContainerConfig[]) { diff --git a/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx index f61763f74..2aaa0793c 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx @@ -1,8 +1,16 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; -import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react"; -import { Button } from "@edr/ui-common"; +import { + Box, + Button, + Divider, + Loader, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertTriangle, CheckCircle2, FileSearch, FileText, Home, RotateCcw } from "lucide-react"; import { api } from "@/services/api"; function extractOrderId(): string | null { @@ -13,6 +21,35 @@ function extractOrderId(): string | null { return segments[segments.length - 1] ?? null; } +function PaymentCard({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + export default function CheckPaymentPage() { const navigate = useNavigate(); const orderId = useMemo(() => extractOrderId(), []); @@ -29,105 +66,307 @@ export default function CheckPaymentPage() { if (!orderId) { return ( -
-
-
- -

- No payment reference found -

- -
-
-
+ + + + + + + No payment reference + + + We could not find a payment order to verify. + + + + + + ); } - return ( -
-
- {isLoading && ( -
- -

- Checking payment status… -

-
- )} + if (isLoading) { + return ( + + + + + Verifying your payment… + + + Please wait, this usually takes a few seconds. + + + + ); + } - {isSuccess && ( -
-
- -
-

- Payment was successful! -

-

- Your booking has been confirmed and payment is complete. -

+ if (isSuccess) { + return ( + + + + + + + Payment verified + + + Your booking is confirmed and payment is complete. + + + + + + + EDR staff will assign a train and you will be notified of any updates. + + + + -
- )} - - {!isLoading && data && !isSuccess && ( -
-
- -
-

- Payment status: {data.status} -

-

- Please try again or contact support if the issue persists. -

-
- )} + + + Questions?{" "} + + support@edr.et + + + + + ); + } - {isError && ( -
-
- -
-

- Something went wrong -

-

+ if (isError) { + return ( + + + + + + + Verification failed + + + We could not verify your payment status. + + + + + + {error instanceof Error ? error.message - : "Failed to check payment status."} -

+ : "An unexpected error occurred. Please try again or contact support."} + + + + -
- )} -
-
+ + + + Need help?{" "} + + support@edr.et + + + + + ); + } + + // Non-success status (e.g. PAY_FAIL, PENDING, etc.) + return ( + + + + + + + Payment incomplete + + + Status:{" "} + + {data?.status ?? "Unknown"} + + + + + + + + Your payment did not complete successfully. Nothing has been charged. + You can retry from your booking page. + + + + + + + + + Need help?{" "} + + support@edr.et + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx index 6b577ac06..3d59c61cb 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx @@ -1,43 +1,171 @@ -import { XCircle } from "lucide-react"; +import { + Box, + Button, + Divider, + Group, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertTriangle, ArrowLeft, Home, RotateCcw } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { Button } from "@edr/ui-common"; -/** - * Public page the payment provider redirects the browser to after a failed or - * cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was - * charged and sends the customer back to their bookings to retry from "Pay now". - */ export default function PaymentFailurePage() { const navigate = useNavigate(); return ( -
-
-
-
- -
-

- Payment was not completed -

-

- Your payment didn't go through and you haven't been charged. You can - try again from your booking using "Pay now". -

-
- -
-
-
-
+ + + + Need help?{" "} + + support@edr.et + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx index 4b2d766e7..de9a20c0e 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx @@ -1,43 +1,170 @@ -import { CheckCircle2 } from "lucide-react"; +import { + Box, + Button, + Divider, + Group, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { CheckCircle2, FileText, Home } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { Button } from "@edr/ui-common"; -/** - * Public page the payment provider redirects the browser to after a successful - * payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the - * customer back to their bookings, where the booking reflects the paid state. - */ export default function PaymentSuccessPage() { const navigate = useNavigate(); return ( -
-
-
-
- -
-

+ + + {/* Green header stripe */} + + + + + Payment successful -

-

- Thank you — your payment has been received. Your booking will be - updated shortly and is now confirmed for scheduling. -

-
- -
-
-
-
+ + + + Questions? Contact{" "} + + support@edr.et + + + + + ); } From 3f81f45c6d80948ea58c6c313752dd56c9040c3a Mon Sep 17 00:00:00 2001 From: marshal Date: Fri, 19 Jun 2026 00:47:46 +0300 Subject: [PATCH 06/15] Update login page logo from SVG to PNG format --- apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx | 2 +- apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 9eb5a8d9b..458843a57 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; -const EDR_LOGO = "/assets/logo.svg"; +const EDR_LOGO = "/assets/edr-logo.png"; type LoginMethod = "email" | "phone"; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 5f1e4b615..43a131e90 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -10,7 +10,7 @@ import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; -const EDR_LOGO = "/assets/logo.svg"; +const EDR_LOGO = "/assets/edr-logo.png"; const passwordRequirements = [ { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, From 1a3d85e46ad5dc5984f92d11fd747fdf6a1c0fa7 Mon Sep 17 00:00:00 2001 From: marshal Date: Fri, 19 Jun 2026 00:53:29 +0300 Subject: [PATCH 07/15] Remove unused ArrowLeft icon import from PaymentFailurePage --- .../portal/src/pages/payments/PaymentFailurePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx index 3d59c61cb..912f65808 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx @@ -7,7 +7,7 @@ import { Text, ThemeIcon, } from "@mantine/core"; -import { AlertTriangle, ArrowLeft, Home, RotateCcw } from "lucide-react"; +import { AlertTriangle, Home, RotateCcw } from "lucide-react"; import { useNavigate } from "react-router-dom"; export default function PaymentFailurePage() { From 72b63c319ee11c6430076c1687a2a3cd200a84b0 Mon Sep 17 00:00:00 2001 From: marshal Date: Fri, 19 Jun 2026 01:07:05 +0300 Subject: [PATCH 08/15] - Fix shipping line mapping to use ID instead of name in EditBookingPage and NewBookingPage --- .../src/components/layout/FreightSidebar.tsx | 8 +++++--- .../src/pages/bookings/EditBookingPage.tsx | 20 +++++++++---------- .../src/pages/bookings/NewBookingPage.tsx | 6 +----- .../bookings/new-booking-form/step4-route.tsx | 12 +++++++---- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index a2d685d0f..bfa75ddd1 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -6,7 +6,7 @@ import { useMemo, useState, } from "react"; -import { ChevronDown, Train } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; @@ -226,8 +226,10 @@ const FreightSidebar = ({ return (
-
- +
+ EDR Freight + + {/* */}
{ if (!referenceData?.shipping_line) return []; - return referenceData.shipping_line.map((sl) => ({ - value: sl.name, - label: sl.name, - })); + const seen = new Set(); + return referenceData.shipping_line + .filter((sl) => { + if (seen.has(sl.id)) return false; + seen.add(sl.id); + return true; + }) + .map((sl) => ({ value: sl.id, label: sl.name })); }, [referenceData]); const setDocument = (key: string, file: File | null) => { @@ -376,12 +380,8 @@ export default function EditBookingPage() { }; const handleSubmit = form.handleSubmit((data) => { - const shippingLines = referenceData?.shipping_line ?? []; const containerGroups = referenceData?.containers ?? []; - const findShippingLineId = (name: string): string | undefined => - shippingLines.find((l) => l.name === name)?.id; - const cargoTypePath = data.cargoTypePath ?? []; const cargoTypeId = data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? ""); @@ -456,7 +456,7 @@ export default function EditBookingPage() { ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress } : {}), ...(data.shippingLine - ? { shippingLineId: findShippingLineId(data.shippingLine) } + ? { shippingLineId: data.shippingLine } : {}), }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index fd068e8ef..7b70ec1c9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -231,13 +231,9 @@ export default function NewBookingPage() { ) : Number(data.cargoWeight || 0); - const shippingLines = referenceData?.shipping_line ?? []; const cargoTree = referenceData?.cargo_type ?? []; const containerGroups = referenceData?.containers ?? []; - const findShippingLineId = (name: string): string | undefined => - shippingLines.find((l) => l.name === name)?.id; - const findContainerTypeId = (name: string): string => { for (const group of containerGroups) { const ct = group.types.find((t) => t.name === name); @@ -308,7 +304,7 @@ export default function NewBookingPage() { ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress } : {}), ...(data.shippingLine - ? { shippingLineId: findShippingLineId(data.shippingLine) } + ? { shippingLineId: data.shippingLine } : {}), ...(cargoFreeText ? { cargoFreeText } : {}), }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index dd5df7d9c..f021e3847 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -35,10 +35,14 @@ export function Step4Route({ const shippingLineOptions = useMemo(() => { if (!referenceData?.shipping_line) return []; - return referenceData.shipping_line.map((sl) => ({ - value: sl.name, - label: sl.name, - })); + const seen = new Set(); + return referenceData.shipping_line + .filter((sl) => { + if (seen.has(sl.id)) return false; + seen.add(sl.id); + return true; + }) + .map((sl) => ({ value: sl.id, label: sl.name })); }, [referenceData]); const originData = useMemo(() => { From f1dfbbcc3f10bca4c605c3d34fdd46b9a4e6fea6 Mon Sep 17 00:00:00 2001 From: marshal Date: Fri, 19 Jun 2026 01:13:52 +0300 Subject: [PATCH 09/15] Replace EDR logo image with Train icon in FreightSidebar --- .../backoffice/src/components/layout/FreightSidebar.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index bfa75ddd1..344e80c18 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -6,7 +6,7 @@ import { useMemo, useState, } from "react"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Train } from "lucide-react"; import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; @@ -226,10 +226,8 @@ const FreightSidebar = ({ return (
-
- EDR Freight - - {/* */} +
+
Date: Fri, 19 Jun 2026 06:12:46 +0300 Subject: [PATCH 10/15] fix: ( notification ) add notification templet and fix issues --- .../migration.sql | 3 ++ apps/edr-passenger-api/prisma/seed.ts | 19 +++++--- .../modules/notifications/dtos/email.dto.ts | 13 ------ .../notifications/email-client.service.ts | 8 +++- .../notifications/notifications.service.ts | 45 +++++++++++++++---- .../notifications/sms-client.service.ts | 18 +++++++- 6 files changed, 75 insertions(+), 31 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql index 7622faf86..3b5beccb8 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql @@ -141,6 +141,9 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; -- AlterTable +-- gender is created here on a clean migration history (no prior migration adds it); +-- on an already-drifted DB where it exists as varchar, normalize it to TEXT. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT; ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT; -- CreateIndex diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 9e1fffa44..bac8a4dd4 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -550,18 +550,22 @@ async function seedSegmentFares() { 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_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' }, - { id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' }, - { id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, - { id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, - { id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, + { 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}}.' }, + // 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 }, - update: {}, + // 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, }); } @@ -684,7 +688,8 @@ async function main() { ['system users', seedSystemUsers], ['fare rules', seedFareRules], ['segment fares', seedSegmentFares], - ['currency', seedCurrency] + ['currency', seedCurrency], + ['notification templates', seedNotificationTemplates] ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts index a435fa4ce..c8fbb74c9 100644 --- a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts @@ -40,18 +40,5 @@ export class SendEmail { @IsOptional() context?: Record; - @ApiPropertyOptional() - @IsOptional() - @IsString() - templateName?: string; - @ApiPropertyOptional() - @IsOptional() - @IsEmail() - from?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsEmail() - replyTo?: string; } diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index 34879ed0a..d2181206a 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -29,11 +29,17 @@ export class EmailClientService implements OnApplicationBootstrap { } async sendEmail(dto: SendEmail) { - if (!this.enabled) return {}; + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to ${dto.to}`); + return {}; + } this.emailServiceClient.emit("send-email", { ...dto, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + this.logger.log( + `EMAIL emitted to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, + ); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 595633e93..1af7c7952 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -169,7 +169,12 @@ export class NotificationsService { private async getUserPreferredChannels(recipient: string): Promise { const user = await this.prisma.user.findFirst({ where: { - OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], + OR: [ + { id: recipient }, + { email: recipient }, + { phone: recipient }, + { passenger: { id: recipient } }, + ], }, include: { preferences: true }, }); @@ -192,7 +197,12 @@ export class NotificationsService { ): Promise { const user = await this.prisma.user.findFirst({ where: { - OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], + OR: [ + { id: recipient }, + { email: recipient }, + { phone: recipient }, + { passenger: { id: recipient } }, + ], }, }); @@ -239,27 +249,46 @@ export class NotificationsService { @OnEvent('booking.created') async onBookingCreated(payload: any) { + const booking = payload.booking; await this.send( 'booking.created', - payload.booking.passengerId, + booking.passengerId, { - bookingRef: payload.booking.bookingRef, + bookingRef: booking.bookingRef, + amount: this.formatAmount(booking), + currency: booking.displayCurrency ?? 'ETB', category: 'BOOKING', - deepLink: `edr://bookings/${payload.booking.bookingRef}`, + deepLink: `edr://bookings/${booking.bookingRef}`, }, + // For now, always notify the travelling passenger on every channel. + ['IN_APP', 'EMAIL', 'SMS'], ); } @OnEvent('payment.succeeded') async onPaymentSucceeded(payload: any) { + const booking = payload.booking; await this.send( 'payment.succeeded', - payload.booking.passengerId, + booking.passengerId, { - bookingRef: payload.booking.bookingRef, + bookingRef: booking.bookingRef, + amount: this.formatAmount(booking), + currency: booking.displayCurrency ?? 'ETB', category: 'PAYMENT', - deepLink: `edr://tickets/${payload.booking.bookingRef}`, + deepLink: `edr://tickets/${booking.bookingRef}`, }, + ['IN_APP', 'EMAIL', 'SMS'], ); } + + /** + * Formats a booking's payable amount from minor units into a major-unit string. + * Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point + * drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00". + */ + private formatAmount(booking: any): string { + const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0; + return (minor / 100).toFixed(2); + } } \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index ef2758686..6ef0a99ac 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -31,20 +31,34 @@ export class SmsClientService implements OnApplicationBootstrap { } async sendSms(dto: SingleMessageDto) { - if (!this.enabled) return {}; + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); + return {}; + } this.smsClient.emit("send-sms", { ...dto, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + this.logger.log( + `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} message="${dto.message}"`, + ); return {}; } async sendBulkMessages(dto: BulkMessagesDto) { - if (!this.enabled) return {}; + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); + return {}; + } this.smsClient.emit("ozeking-bulk-sms", { ...dto, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + this.logger.log( + `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${dto.messages?.length ?? 0} messages=${JSON.stringify( + (dto.messages ?? []).map((m) => ({ to: m.to, message: m.message })), + )}`, + ); return {}; } } From 5e1d53b866d457b29ad991dccbeb49dfceeea84e Mon Sep 17 00:00:00 2001 From: yaschalew Date: Fri, 19 Jun 2026 08:48:58 +0300 Subject: [PATCH 11/15] fix file upload --- apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 4425c513e..91113cc45 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -33,7 +33,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab const docSettingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: "customer_documents" }, + input: { code: "customer_file_documents" }, }), ); From 525e724461db51b373d4bd0d48da8687571b93c6 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 10:19:11 +0300 Subject: [PATCH 12/15] fix: ( payment ) pass major amount to the payment provider --- .../src/modules/notifications/sms-client.service.ts | 6 ++++-- .../src/modules/payments/payments.service.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 6ef0a99ac..a71ee882d 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -35,12 +35,14 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); return {}; } + // The external send-sms consumer reads the content from `sms`, not `message`. this.smsClient.emit("send-sms", { - ...dto, + to: dto.to, + sms: dto.message, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} message="${dto.message}"`, + `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} sms="${dto.message}"`, ); return {}; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index c8d0580bf..871105b31 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -137,7 +137,9 @@ export class PaymentsService { referenceType: PaymentReferenceType.BOOKING, referenceId: booking.id, orderRef: booking.bookingRef, - amountMinor: booking.totalMinor, + // Send the REAL (major) price, not minor units. The payment API no longer divides by 100 + // (freight already passes the real price), so the providers charge this value as-is. + amountMinor: booking.totalMinor / 100, currency: booking.currency, provider: method as unknown as ProviderMethod, platform: dto.platform, @@ -634,11 +636,15 @@ export class PaymentsService { return { processed: false, reason: "booking-not-found" }; } - if (booking.totalMinor !== event.amountMinor) { + // The event carries the REAL (major) price the provider charged (passenger now sends + // booking.totalMinor/100 on initiate), so convert it back to minor units before comparing + // with booking.totalMinor (which is in minor units). + const eventAmountMinor = Math.round(event.amountMinor * 100); + if (booking.totalMinor !== eventAmountMinor) { // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, // which is the alertable signal for an asserted-vs-paid amount divergence. this.logger.error( - `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`, + `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`, ); throw new BadRequestException( "Event amount does not match booking total", From a2b2ed787e1fef7cd71203b9191c6b3ba15e7960 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 11:17:56 +0300 Subject: [PATCH 13/15] fix: ( sms ) change the key sms to text --- .../src/modules/notifications/sms-client.service.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index a71ee882d..5421a6efa 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -35,14 +35,13 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); return {}; } - // The external send-sms consumer reads the content from `sms`, not `message`. this.smsClient.emit("send-sms", { to: dto.to, - sms: dto.message, + text: dto.message, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} sms="${dto.message}"`, + `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} text="${dto.message}"`, ); return {}; } @@ -52,14 +51,13 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); return {}; } + const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from })); this.smsClient.emit("ozeking-bulk-sms", { - ...dto, + messages, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${dto.messages?.length ?? 0} messages=${JSON.stringify( - (dto.messages ?? []).map((m) => ({ to: m.to, message: m.message })), - )}`, + `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} messages=${JSON.stringify(messages)}`, ); return {}; } From fcbc59e95b268aa9fbe1bd327f44b7350407a800 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 11:41:31 +0300 Subject: [PATCH 14/15] refactor: ( notifications ) remove dead SendGrid/Twilio adapters and legacy code --- apps/edr-passenger-api/package.json | 1 - .../notifications/notification.adapters.ts | 189 ------------------ .../notifications/notifications.module.ts | 6 +- .../notifications/notifications.service.ts | 38 ---- pnpm-lock.yaml | 35 ---- 5 files changed, 1 insertion(+), 268 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 84ea472d4..8ef7e669a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -34,7 +34,6 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", "@prisma/client": "^6.19.3", - "@sendgrid/mail": "^8.1.0", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", diff --git a/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts b/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts index c7db1a0a4..b7df05276 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts @@ -1,199 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import * as sgMail from '@sendgrid/mail'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; export interface NotificationChannel { send(recipient: string, subject: string, body: string, context?: Record): Promise; } -@Injectable() -export class EmailAdapter implements NotificationChannel { - private readonly logger = new Logger(EmailAdapter.name); - - constructor(private readonly config: ConfigService) { - const apiKey = this.config.get('SENDGRID_API_KEY'); - if (apiKey) { - sgMail.setApiKey(apiKey); - this.logger.log('SendGrid Email adapter initialized'); - } else { - this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only'); - } - } - - async send( - recipient: string, - subject: string, - body: string, - context?: Record, - ): Promise { - const apiKey = this.config.get('SENDGRID_API_KEY'); - const fromEmail = this.config.get('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com'; - - if (!apiKey) { - this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`); - return true; - } - - try { - const msg: sgMail.MailDataRequired = { - to: recipient, - from: fromEmail, - subject, - text: body, - html: this.formatHtml(body, context), - }; - - await sgMail.send(msg); - this.logger.log(`Email sent successfully to ${recipient}`); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Failed to send email to ${recipient}: ${message}`); - return false; - } - } - - private formatHtml(body: string, context?: Record): string { - const contextHtml = context - ? `
- ${JSON.stringify(context, null, 2)} -
` - : ''; - - return ` - - - - - - - -
-
-

Ethio-Djibouti Railway

-
-
- ${body.replace(/\n/g, '
')} - ${contextHtml} -
- -
- - - `; - } -} - -@Injectable() -export class SmsAdapter implements NotificationChannel { - private readonly logger = new Logger(SmsAdapter.name); - - constructor( - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - const provider = this.config.get('SMS_PROVIDER'); - this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`); - } - - async send( - recipient: string, - subject: string, - body: string, - _context?: Record, - ): Promise { - const provider = this.config.get('SMS_PROVIDER'); - const apiKey = this.config.get('SMS_API_KEY'); - - if (!provider || !apiKey) { - this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`); - return true; - } - - try { - switch (provider.toLowerCase()) { - case 'twilio': - return await this.sendViaTwilio(recipient, body); - case 'africastalking': - return await this.sendViaAfricasTalking(recipient, body); - default: - this.logger.warn(`Unknown SMS provider: ${provider}`); - return false; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Failed to send SMS to ${recipient}: ${message}`); - return false; - } - } - - private async sendViaTwilio(to: string, body: string): Promise { - const accountSid = this.config.get('TWILIO_ACCOUNT_SID'); - const authToken = this.config.get('TWILIO_AUTH_TOKEN'); - const fromNumber = this.config.get('TWILIO_FROM_NUMBER'); - - const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`; - const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64'); - - const response = await firstValueFrom( - this.http.post( - url, - new URLSearchParams({ - To: to, - From: fromNumber || '', - Body: body, - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${auth}`, - }, - }, - ), - ); - - return response.status === 201; - } - - private async sendViaAfricasTalking(to: string, body: string): Promise { - const apiKey = this.config.get('SMS_API_KEY'); - const username = this.config.get('AFRICASTALKING_USERNAME'); - const from = this.config.get('AFRICASTALKING_FROM'); - - const url = 'https://api.africastalking.com/version1/messaging'; - - const response = await firstValueFrom( - this.http.post( - url, - new URLSearchParams({ - username: username || '', - to, - message: body, - from: from || '', - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'apiKey': apiKey || '', - }, - }, - ), - ); - - return response.status === 201; - } -} - @Injectable() export class PushAdapter implements NotificationChannel { private readonly logger = new Logger(PushAdapter.name); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index c72552015..0ee178a03 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,15 +1,13 @@ import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; -import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters'; +import { PushAdapter } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @Module({ imports: [ - HttpModule.register({ timeout: 10_000 }), ClientsModule.register([ { name: 'EMAIL_SERVICE', @@ -34,8 +32,6 @@ import { SmsClientService } from './sms-client.service'; controllers: [NotificationsController], providers: [ NotificationsService, - EmailAdapter, - SmsAdapter, PushAdapter, EmailClientService, SmsClientService, diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 1af7c7952..3f4e25984 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { PrismaService } from '../../common/prisma.service'; -import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto'; import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @@ -83,37 +82,6 @@ export class NotificationsService { return { sent: sentChannels.length > 0, channels: sentChannels }; } - /** - * Legacy method for backward compatibility - */ - async sendDirect(dto: SendNotificationDto) { - const notification = await this.prisma.notification.create({ - data: { - passengerId: dto.passengerId, - title: dto.title, - body: dto.body, - category: dto.category as any, - deepLink: dto.deepLink, - metadata: dto.metadata, - }, - }); - - const passenger = await this.prisma.passenger.findUnique({ - where: { id: dto.passengerId }, - include: { user: true }, - }); - - if (passenger?.user) { - await this.emailClient.sendEmail({ - to: passenger.user.email, - subject: this.sanitize(dto.title), - text: this.sanitize(dto.body), - }); - } - - return notification; - } - private async createInAppNotification( recipient: string, title: string, @@ -221,12 +189,6 @@ export class NotificationsService { } } - private sanitize(value: string): string { - return value - .replace(/[\r\n]/g, ' ') - .replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c)); - } - getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1b29edac..59c0e1d63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -452,9 +452,6 @@ importers: '@prisma/client': specifier: ^6.19.3 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) - '@sendgrid/mail': - specifier: ^8.1.0 - version: 8.1.6 axios: specifier: ^1.7.7 version: 1.17.0 @@ -3756,18 +3753,6 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sendgrid/client@8.1.6': - resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==} - engines: {node: '>=12.*'} - - '@sendgrid/helpers@8.0.0': - resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==} - engines: {node: '>= 12.0.0'} - - '@sendgrid/mail@8.1.6': - resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==} - engines: {node: '>=12.*'} - '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -16642,26 +16627,6 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sendgrid/client@8.1.6': - dependencies: - '@sendgrid/helpers': 8.0.0 - axios: 1.17.0 - transitivePeerDependencies: - - debug - - supports-color - - '@sendgrid/helpers@8.0.0': - dependencies: - deepmerge: 4.3.1 - - '@sendgrid/mail@8.1.6': - dependencies: - '@sendgrid/client': 8.1.6 - '@sendgrid/helpers': 8.0.0 - transitivePeerDependencies: - - debug - - supports-color - '@sinclair/typebox@0.27.10': {} '@sindresorhus/merge-streams@4.0.0': {} From 2288a9efda1086911d5adf1188a6aa65a054f3d0 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 15:49:30 +0300 Subject: [PATCH 15/15] feat: ( notifications ) add payment/cancellation events and ticket confirmation --- apps/edr-passenger-api/prisma/seed.ts | 2 + .../src/modules/bookings/bookings.service.ts | 1 + .../notifications/email-client.service.ts | 15 +- .../notifications/notifications.module.ts | 3 + .../notifications/notifications.service.ts | 270 ++++++++++++++++-- .../notifications/sms-client.service.ts | 22 +- .../src/modules/payments/payments.service.ts | 6 + 7 files changed, 280 insertions(+), 39 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index bac8a4dd4..34e5e1dc8 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -555,6 +555,8 @@ async function seedNotificationTemplates() { 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' }, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b40a5c0ee..44a31c151 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1007,6 +1007,7 @@ export class BookingsService { await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); + this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; } diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index d2181206a..e50ac8037 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -28,18 +28,23 @@ export class EmailClientService implements OnApplicationBootstrap { ); } - async sendEmail(dto: SendEmail) { + async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> { if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped EMAIL to ${dto.to}`); - return {}; + this.logger.warn(`RABBITMQ disabled — skipped EMAIL`); + return { queued: false }; } this.emailServiceClient.emit("send-email", { ...dto, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + // Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered. this.logger.log( - `EMAIL emitted to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, ); - return {}; + // Recipient + content are PII — keep them at debug level only. + this.logger.debug( + `EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, + ); + return { queued: true }; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index 0ee178a03..c76a7e0bd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; @@ -8,6 +9,8 @@ import { SmsClientService } from './sms-client.service'; @Module({ imports: [ + // Required by IamGuard (injects HttpService) used in NotificationsController. + HttpModule.register({ timeout: 10_000 }), ClientsModule.register([ { name: 'EMAIL_SERVICE', diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 3f4e25984..0c6add4b2 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -19,8 +19,8 @@ export class NotificationsService { private pushAdapter: PushAdapter, ) { this.channels = new Map([ - ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], - ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], + ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }], + ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }], ['PUSH', this.pushAdapter as NotificationChannel], ]); } @@ -37,27 +37,38 @@ export class NotificationsService { recipient: string, context: Record, channels?: NotificationChannelType[], - ): Promise<{ sent: boolean; channels: string[] }> { + ): Promise<{ queued: boolean; channels: string[] }> { const template = await this.prisma.notificationTemplate.findUnique({ where: { code: templateKey }, }); if (!template || !template.active) { this.logger.warn(`Template ${templateKey} not found or inactive`); - return { sent: false, channels: [] }; + return { queued: false, channels: [] }; } const { subject, body } = this.interpolate(template, context); - const targetChannels = channels || await this.getUserPreferredChannels(recipient); - const sentChannels: string[] = []; - // Always create in-app notification - if (targetChannels.includes('IN_APP')) { - await this.createInAppNotification(recipient, subject, body, context); - sentChannels.push('IN_APP'); + // Channel resolution: explicit argument wins; otherwise honor the template's declared + // channel(s); otherwise fall back to the recipient's preferences. + let targetChannels: NotificationChannelType[]; + if (channels) { + targetChannels = channels; + } else if (template.channel) { + targetChannels = this.parseTemplateChannels(template.channel); + } else { + targetChannels = await this.getUserPreferredChannels(recipient); + } + + // Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ). + // NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation. + const queuedChannels: string[] = []; + + if (targetChannels.includes('IN_APP')) { + await this.createInAppNotification(recipient, subject, body, context); + queuedChannels.push('IN_APP'); } - // Send via other channels for (const channelType of targetChannels) { if (channelType === 'IN_APP') continue; @@ -73,13 +84,26 @@ export class NotificationsService { continue; } - const success = await adapter.send(recipientAddress, subject, body, context); - if (success) { - sentChannels.push(channelType); + const queued = await adapter.send(recipientAddress, subject, body, context); + if (queued) { + queuedChannels.push(channelType); } } - return { sent: sentChannels.length > 0, channels: sentChannels }; + return { queued: queuedChannels.length > 0, channels: queuedChannels }; + } + + /** + * Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel + * types, always including IN_APP so an in-app record is created. + */ + private parseTemplateChannels(channel: string): NotificationChannelType[] { + const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; + const parsed = channel + .split(',') + .map((c) => c.trim().toUpperCase()) + .filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType)); + return Array.from(new Set(['IN_APP', ...parsed])); } private async createInAppNotification( @@ -122,16 +146,20 @@ export class NotificationsService { template: { subject?: string | null; bodyTemplate: string }, context: Record, ): { subject: string; body: string } { - const subject = template.subject || 'Notification'; - let body = template.bodyTemplate; + return { + subject: this.applyVars(template.subject || 'Notification', context), + body: this.applyVars(template.bodyTemplate, context), + }; + } - // Simple template interpolation: {{variable}} + /** Replaces {{variable}} placeholders in a string with values from the context. */ + private applyVars(text: string, context: Record): string { + let out = text; for (const [key, value] of Object.entries(context)) { const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); - body = body.replace(regex, String(value)); + out = out.replace(regex, String(value)); } - - return { subject, body }; + return out; } private async getUserPreferredChannels(recipient: string): Promise { @@ -227,18 +255,210 @@ export class NotificationsService { ); } + /** + * Payment succeeded → one combined "payment successful, here is your ticket" notification. + * Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow + * event payload is re-fetched with the relations needed to render the ticket. + */ @OnEvent('payment.succeeded') async onPaymentSucceeded(payload: any) { + const passengerId = payload.booking.passengerId; + const bookingId = payload.booking.id; + + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + }, + }); + const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } }); + + const ref = booking?.bookingRef ?? payload.booking.bookingRef; + const amount = this.formatAmount(booking ?? payload.booking); + const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB'; + const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`; + + // IN_APP — always created. + await this.createInAppNotification( + passengerId, + 'Payment successful', + `Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`, + { category: 'PAYMENT', deepLink: `edr://tickets/${ref}` }, + ); + + // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation. + if (!ticket || !booking) { + this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`); + const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`; + await this.deliverEmail(passengerId, `Payment received — ${ref}`, text); + await this.deliverSms(passengerId, text); + return; + } + + // SMS — short pointer (no HTML/QR over SMS). + await this.deliverSms( + passengerId, + `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, + ); + + // EMAIL — rich HTML ticket with plain-text fallback. + await this.deliverEmail( + passengerId, + `Your EDR ticket — ${ref}`, + this.buildTicketEmailText(booking, amount, currency, ticketUrl), + this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl), + ); + } + + private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise { + const to = await this.getRecipientAddress(recipient, 'EMAIL'); + if (!to) { + this.logger.warn(`No EMAIL address for recipient: ${recipient}`); + return; + } + await this.emailClient.sendEmail({ to, subject, text, html }); + } + + private async deliverSms(recipient: string, message: string): Promise { + const to = await this.getRecipientAddress(recipient, 'SMS'); + if (!to) { + this.logger.warn(`No SMS address for recipient: ${recipient}`); + return; + } + await this.smsClient.sendSms({ to, message }); + } + + private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string { + const s = booking.schedule ?? {}; + const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; + const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); + return [ + `Booking ${booking.bookingRef} confirmed.`, + `${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`, + `Train: ${s.train?.name ?? s.train?.number ?? ''}`, + `Departs: ${dep}`, + passengers ? `Passengers: ${passengers}` : '', + `Total paid: ${amount} ${currency}`, + `View your ticket: ${url}`, + ].filter(Boolean).join('\n'); + } + + private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string { + const s = booking.schedule ?? {}; + const fmt = (d: any) => + d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; + const seatRows = (booking.seats ?? []) + .map((bs: any) => { + const coach = bs.seat?.coach?.number ?? '-'; + const seatNo = bs.seat?.seatNumber ?? '-'; + const cls = bs.seat?.coach?.coachType?.name ?? '-'; + return ` + ${bs.passengerName ?? ''} + ${coach} + ${seatNo} + ${cls} + `; + }) + .join(''); + + return ` + + + +
+
+

Ethio-Djibouti Railway

+

Payment successful — your ticket is ready

+
+
+

Booking reference: ${booking.bookingRef}

+ + + + + + + + + + + + + + + + + + + + + +
From${s.originStation?.name ?? ''} (${s.originStation?.code ?? ''})
To${s.destinationStation?.name ?? ''} (${s.destinationStation?.code ?? ''})
Train${s.train?.name ?? s.train?.number ?? ''}
Departs${fmt(s.departureAt)}
Arrives${fmt(s.arrivalAt)}
+ +

Passengers

+ + + + + + + + ${seatRows} +
NameCoachSeatClass
+ +
+

Show this QR code at the gate

+ Ticket QR code +
+ + + + + + +
Total paid${amount} ${currency}
+ + +
+
+

© Ethio-Djibouti Railway. All rights reserved.

+
+
+ +`; + } + + @OnEvent('payment.failed') + async onPaymentFailed(payload: any) { const booking = payload.booking; await this.send( - 'payment.succeeded', + 'payment.failed', booking.passengerId, { bookingRef: booking.bookingRef, - amount: this.formatAmount(booking), - currency: booking.displayCurrency ?? 'ETB', category: 'PAYMENT', - deepLink: `edr://tickets/${booking.bookingRef}`, + deepLink: `edr://bookings/${booking.bookingRef}`, + }, + ['IN_APP', 'EMAIL', 'SMS'], + ); + } + + @OnEvent('booking.cancelled') + async onBookingCancelled(payload: any) { + const booking = payload.booking; + await this.send( + 'booking.cancelled', + booking.passengerId, + { + bookingRef: booking.bookingRef, + // refundAmount is computed in ETB minor units in BookingsService.cancel(). + refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2), + currency: 'ETB', + category: 'BOOKING', + deepLink: `edr://bookings/${booking.bookingRef}`, }, ['IN_APP', 'EMAIL', 'SMS'], ); diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 5421a6efa..f94c0c20e 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -30,26 +30,29 @@ export class SmsClientService implements OnApplicationBootstrap { }); } - async sendSms(dto: SingleMessageDto) { + async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> { if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); - return {}; + this.logger.warn(`RABBITMQ disabled — skipped SMS`); + return { queued: false }; } this.smsClient.emit("send-sms", { to: dto.to, text: dto.message, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. this.logger.log( - `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} text="${dto.message}"`, + `SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`, ); - return {}; + // Recipient + content are PII — debug only. + this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`); + return { queued: true }; } - async sendBulkMessages(dto: BulkMessagesDto) { + async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> { if (!this.enabled) { this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); - return {}; + return { queued: false }; } const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from })); this.smsClient.emit("ozeking-bulk-sms", { @@ -57,8 +60,9 @@ export class SmsClientService implements OnApplicationBootstrap { appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} messages=${JSON.stringify(messages)}`, + `BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`, ); - return {}; + this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`); + return { queued: true }; } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 871105b31..13569ffc4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -622,6 +622,12 @@ export class PaymentsService { failureMessage: event.failureMessage, }); } + const failedBooking = await this.prisma.booking.findUnique({ + where: { id: event.referenceId }, + }); + if (failedBooking) { + this.eventEmitter.emit("payment.failed", { booking: failedBooking }); + } return { processed: true }; }