diff --git a/apps/edr-passenger-api/SEGMENT_BASED_SEATS.md b/apps/edr-passenger-api/SEGMENT_BASED_SEATS.md new file mode 100644 index 000000000..d1d1a6212 --- /dev/null +++ b/apps/edr-passenger-api/SEGMENT_BASED_SEATS.md @@ -0,0 +1,364 @@ +# Segment-Based Seat Reservation + +## Overview + +This implementation introduces segment-based seat reservation and release logic for the Ethio-Djibouti Railway passenger booking system. It allows passengers to book partial journeys while ensuring optimal seat utilization through automatic release when passengers reach their destinations. + +## Key Features + +- **Segment-based reservations**: Book seats for specific route segments (e.g., Addis Ababa → Dire Dawa) +- **Automatic seat release**: Seats are released when passengers reach their destination +- **Concurrency control**: Database transactions ensure consistency +- **Real-time updates**: Event-driven notifications for seat availability changes +- **Hold expiration**: Automatic cleanup of expired seat holds + +## Route Example + +**Full Route**: Addis Ababa → Adama → Awash → Dire Dawa → Djibouti + +**Passenger Journey**: Addis Ababa → Dire Dawa +- **Segments**: [Addis→Adama, Adama→Awash, Awash→Dire Dawa] +- **Seat Status**: HELD → BOOKED → AVAILABLE (when reaching Dire Dawa) + +## Database Schema Integration + +### Core Tables Used + +```sql +-- Trip and route structure +Trip, TripStopTime, Station + +-- Seat management +Seat, SeatHold, BookingSeat, Booking + +-- Journey tracking +JourneySegment (stores segment-to-seat mapping) + +-- Real-time progress +TripLiveStatus (triggers seat releases) +``` + +### Key Enums + +```typescript +enum SeatStatus { + AVAILABLE = 'AVAILABLE', + HELD = 'HELD', + BOOKED = 'BOOKED', + BLOCKED = 'BLOCKED' +} +``` + +## API Endpoints + +### 1. Check Seat Availability + +```http +GET /segments/seats/availability?tripId=trip_001&originStationId=st_ADD&destinationStationId=st_DRE +``` + +**Response:** +```json +{ + "segments": [ + { "fromName": "Addis Ababa", "toName": "Adama", "fromSequence": 0, "toSequence": 1 }, + { "fromName": "Adama", "toName": "Awash", "fromSequence": 1, "toSequence": 2 }, + { "fromName": "Awash", "toName": "Dire Dawa", "fromSequence": 2, "toSequence": 3 } + ], + "availableSeats": [ + { "id": "seat_1", "label": "1A", "coach": "A", "serviceClass": "ECONOMY" } + ], + "totalAvailable": 1 +} +``` + +### 2. Hold Seats + +```http +POST /segments/seats/hold +``` + +**Request:** +```json +{ + "tripId": "trip_001", + "seatIds": ["seat_1", "seat_2"], + "passengerId": "passenger_123", + "originStationId": "st_ADD", + "destinationStationId": "st_DRE", + "fareQuoteId": "quote_456" +} +``` + +**Response:** +```json +{ + "holdId": "hold_789", + "expiresAt": "2024-01-15T10:10:00Z", + "segments": [ + { "fromName": "Addis Ababa", "toName": "Adama" }, + { "fromName": "Adama", "toName": "Awash" }, + { "fromName": "Awash", "toName": "Dire Dawa" } + ], + "seats": ["seat_1", "seat_2"] +} +``` + +### 3. Confirm Booking + +```http +POST /segments/seats/confirm +``` + +**Request:** +```json +{ + "holdId": "hold_789", + "bookingId": "booking_123" +} +``` + +### 4. Release Seats (Automatic) + +```http +POST /segments/seats/release +``` + +**Request:** +```json +{ + "tripId": "trip_001", + "currentStationId": "st_DRE" +} +``` + +## Database Transaction Flow + +### 1. Seat Hold Transaction + +```typescript +async function holdSeatsTransaction(request: SeatHoldRequest) { + return prisma.$transaction(async (tx) => { + // 1. Validate seat availability + const seats = await tx.seat.findMany({ + where: { id: { in: request.seatIds } } + }); + + // 2. Check for overlapping reservations + for (const seatId of request.seatIds) { + const overlaps = await checkOverlaps(tx, tripId, seatId, segments); + if (overlaps.length > 0) throw new ConflictException(); + } + + // 3. Create hold record + const hold = await tx.seatHold.create({ + data: { + tripId: request.tripId, + seatIds: request.seatIds, + passengerId: request.passengerId, + expiresAt: new Date(Date.now() + 10 * 60 * 1000) + } + }); + + // 4. Update seat status + await tx.seat.updateMany({ + where: { id: { in: request.seatIds } }, + data: { status: 'HELD', heldUntil: hold.expiresAt } + }); + + return hold; + }); +} +``` + +### 2. Booking Confirmation Transaction + +```typescript +async function confirmBookingTransaction(holdId: string, bookingId: string) { + return prisma.$transaction(async (tx) => { + // 1. Validate hold + const hold = await tx.seatHold.findUnique({ where: { id: holdId } }); + if (!hold || hold.expiresAt < new Date()) { + throw new BadRequestException('Hold expired'); + } + + // 2. Create journey segments + for (const seatId of hold.seatIds) { + for (let i = 0; i < segments.length; i++) { + await tx.journeySegment.create({ + data: { + journeyId: bookingId, + tripId: hold.tripId, + segmentOrder: i + 1, + seatId, + departureStationId: segments[i].fromStationId, + arrivalStationId: segments[i].toStationId + } + }); + } + } + + // 3. Update seat status to BOOKED + await tx.seat.updateMany({ + where: { id: { in: hold.seatIds } }, + data: { status: 'BOOKED', heldUntil: null } + }); + + // 4. Delete hold + await tx.seatHold.delete({ where: { id: holdId } }); + + return { bookingId, confirmedSeats: hold.seatIds }; + }); +} +``` + +### 3. Seat Release Transaction + +```typescript +async function releaseSeatsTransaction(tripId: string, currentStationId: string) { + return prisma.$transaction(async (tx) => { + // 1. Find completed journey segments + const completedSegments = await tx.journeySegment.findMany({ + where: { tripId, arrivalStationId: currentStationId }, + include: { journey: { include: { journeySegments: true } } } + }); + + const seatsToRelease = []; + + // 2. Check if passenger's entire journey is complete + for (const segment of completedSegments) { + const allSegments = segment.journey.journeySegments + .filter(js => js.seatId === segment.seatId); + const maxOrder = Math.max(...allSegments.map(js => js.segmentOrder)); + + if (segment.segmentOrder === maxOrder) { + seatsToRelease.push(segment.seatId); + } + } + + // 3. Release seats + if (seatsToRelease.length > 0) { + await tx.seat.updateMany({ + where: { id: { in: seatsToRelease } }, + data: { status: 'AVAILABLE' } + }); + } + + return { releasedSeats: seatsToRelease }; + }); +} +``` + +## Real-Time Integration + +### Trip Progress Updates + +```typescript +// When train reaches a station +await tripProgressService.updateTripProgress(tripId, stationId, progressPercent); + +// Automatically triggers seat release +this.eventEmitter.emit('trip.station.arrived', { + tripId, + stationId, + stationName: 'Dire Dawa' +}); +``` + +### Event Listeners + +```typescript +@OnEvent('trip.station.arrived') +async handleStationArrival(payload: { tripId: string, stationId: string }) { + await this.enhancedSeatsService.releaseSeats(payload.tripId, payload.stationId); +} + +@OnEvent('seats.released') +async handleSeatsReleased(payload: { releasedSeats: string[] }) { + // Notify waiting passengers about newly available seats + this.notificationService.notifyAvailability(payload.releasedSeats); +} +``` + +## Background Jobs + +### Hold Expiration (Every Minute) + +```typescript +@Cron(CronExpression.EVERY_MINUTE) +async expireHolds() { + const expired = await this.prisma.seatHold.findMany({ + where: { expiresAt: { lt: new Date() } } + }); + + // Release expired seats + await this.prisma.seat.updateMany({ + where: { id: { in: expiredSeatIds } }, + data: { status: 'AVAILABLE', heldUntil: null } + }); +} +``` + +## Usage Examples + +### Complete Booking Flow + +```typescript +// 1. Check availability +const availability = await segmentSeatsService.getSeatAvailability( + 'trip_001', 'st_ADD', 'st_DRE' +); + +// 2. Hold seats (10-minute expiry) +const hold = await segmentSeatsService.holdSeats({ + tripId: 'trip_001', + seatIds: ['seat_1'], + passengerId: 'passenger_123', + originStationId: 'st_ADD', + destinationStationId: 'st_DRE' +}); + +// 3. Process payment... +await paymentService.processPayment(bookingId); + +// 4. Confirm booking +const booking = await segmentSeatsService.confirmBooking({ + holdId: hold.holdId, + bookingId: 'booking_456' +}); + +// 5. Seats automatically released when train reaches Dire Dawa +``` + +## Error Handling + +- **Seat Conflicts**: `ConflictException` when seats overlap with existing reservations +- **Expired Holds**: `BadRequestException` when trying to confirm expired holds +- **Invalid Segments**: `BadRequestException` for invalid origin/destination combinations +- **Transaction Rollback**: Automatic rollback on any failure within transactions + +## Performance Considerations + +- **Indexing**: Ensure indexes on `tripId`, `seatId`, `stationId`, `expiresAt` +- **Batch Operations**: Use `updateMany` for bulk seat status updates +- **Event Queuing**: Consider message queues for high-volume seat release events +- **Caching**: Cache frequently accessed route/station data + +## Integration Notes + +1. **Existing Booking System**: Extends current booking flow with segment awareness +2. **Payment Integration**: Hold expiry provides payment processing window +3. **Real-time Updates**: WebSocket notifications for seat availability changes +4. **Mobile Apps**: Push notifications when seats become available on preferred routes +5. **Analytics**: Track seat utilization patterns by segment for route optimization + +## Testing + +Run the example booking flow: + +```bash +cd apps/edr-passenger-api +npx ts-node src/modules/segments/booking-flow-example.ts +``` + +This demonstrates the complete segment-based reservation lifecycle with database transactions and real-time seat releases. \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/migrations/20260515133041_segment_updates/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260515133041_segment_updates/migration.sql new file mode 100644 index 000000000..e1091458f --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260515133041_segment_updates/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "Journey" ( + "id" TEXT NOT NULL, + "passengerId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Journey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JourneySegment" ( + "id" TEXT NOT NULL, + "journeyId" TEXT NOT NULL, + "tripId" TEXT NOT NULL, + "segmentOrder" INTEGER NOT NULL, + "seatId" TEXT, + "coachId" TEXT, + "departureStationId" TEXT NOT NULL, + "arrivalStationId" TEXT NOT NULL, + + CONSTRAINT "JourneySegment_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index f4103c7a6..afdae475f 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -210,14 +210,15 @@ model Trip { stopsCount Int @default(0) onTimePercent Int @default(100) carbonRating String @default("A") - service TrainService @relation(fields: [serviceId], references: [id]) - originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) - destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) + service TrainService @relation(fields: [serviceId], references: [id]) + originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) + destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) coaches Coach[] bookings Booking[] stopTimes TripStopTime[] liveStatus TripLiveStatus? menuItems MenuItem[] + journeySegments JourneySegment[] } model TripStopTime { @@ -571,3 +572,26 @@ model SavedRoute { createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) } + +model Journey { + id String @id @default(uuid()) + passengerId String + status String + totalMinor Int + currency String @default("ETB") + createdAt DateTime @default(now()) + journeySegments JourneySegment[] +} + +model JourneySegment { + id String @id @default(uuid()) + journeyId String + tripId String + segmentOrder Int + seatId String? + coachId String? + departureStationId String + arrivalStationId String + journey Journey @relation(fields: [journeyId], references: [id]) + trip Trip @relation(fields: [tripId], references: [id]) +} diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 5b594f16e..d7c43f85b 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -22,6 +22,7 @@ import { PromosModule } from './modules/promos/promos.module'; import { LiveModule } from './modules/live/live.module'; import { SupportModule } from './modules/support/support.module'; import { DashboardModule } from './modules/dashboard/dashboard.module'; +import { SegmentsModule } from './modules/segments/segments.module'; @Module({ imports: [ @@ -46,6 +47,7 @@ import { DashboardModule } from './modules/dashboard/dashboard.module'; LiveModule, SupportModule, DashboardModule, + SegmentsModule, ], }) export class AppModule {} diff --git a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts new file mode 100644 index 000000000..04889e392 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts @@ -0,0 +1,392 @@ +/** + * SEGMENT-BASED SEAT RESERVATION EXAMPLE + * + * This example demonstrates the complete flow for booking Addis Ababa → Dire Dawa + * on the Addis Ababa → Djibouti route with segment-based seat management. + * + * Route: Addis Ababa (seq:0) → Adama (seq:1) → Awash (seq:2) → Dire Dawa (seq:3) → Djibouti (seq:4) + * Booking: Addis Ababa → Dire Dawa (segments: 0→1, 1→2, 2→3) + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// Example 1: Complete Booking Flow +async function exampleBookingFlow() { + console.log('=== SEGMENT-BASED BOOKING FLOW ===\n'); + + const tripId = 'trip_add_dji_001'; + const passengerId = 'passenger_kelemu'; + const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b']; + const originStationId = 'st_ADD'; // Addis Ababa + const destinationStationId = 'st_DRE'; // Dire Dawa + + try { + // Step 1: Check seat availability for segments + console.log('1. Checking seat availability...'); + const segments = await getJourneySegments(tripId, originStationId, destinationStationId); + console.log('Journey segments:', segments.map(s => `${s.fromName} → ${s.toName}`)); + + // Step 2: Hold seats (10-minute expiry) + console.log('\n2. Holding seats...'); + const holdResult = await holdSeatsTransaction(tripId, seatIds, passengerId, originStationId, destinationStationId); + console.log('Hold created:', holdResult); + + // Step 3: Simulate payment processing (5 seconds) + console.log('\n3. Processing payment...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Step 4: Confirm booking + console.log('\n4. Confirming booking...'); + const bookingId = 'booking_' + Date.now(); + const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments); + console.log('Booking confirmed:', confirmResult); + + // Step 5: Simulate trip progress and seat release + console.log('\n5. Simulating trip progress...'); + await simulateTripProgress(tripId, segments); + + } catch (error) { + console.error('Booking flow error:', error); + } +} + +// Database Transaction Functions + +async function getJourneySegments(tripId: string, originStationId: string, destinationStationId: string) { + const stopTimes = await prisma.tripStopTime.findMany({ + where: { tripId }, + include: { station: true }, + orderBy: { sequence: 'asc' } + }); + + const originStop = stopTimes.find(st => st.stationId === originStationId); + const destinationStop = stopTimes.find(st => st.stationId === destinationStationId); + + if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) { + throw new Error('Invalid origin/destination'); + } + + const segments = []; + for (let i = originStop.sequence; i < destinationStop.sequence; i++) { + const fromStop = stopTimes.find(st => st.sequence === i); + const toStop = stopTimes.find(st => st.sequence === i + 1); + + if (fromStop && toStop) { + segments.push({ + fromStationId: fromStop.stationId, + toStationId: toStop.stationId, + fromSequence: fromStop.sequence, + toSequence: toStop.sequence, + fromName: fromStop.station.name, + toName: toStop.station.name + }); + } + } + + return segments; +} + +async function holdSeatsTransaction(tripId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) { + return prisma.$transaction(async (tx) => { + console.log(' → Starting seat hold transaction...'); + + // 1. Validate seats exist and are available + const seats = await tx.seat.findMany({ + where: { id: { in: seatIds } }, + include: { coach: true } + }); + + if (seats.length !== seatIds.length) { + throw new Error('Some seats not found'); + } + + for (const seat of seats) { + if (seat.status !== 'AVAILABLE') { + throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`); + } + } + + // 2. Check for overlapping reservations + const segments = await getJourneySegments(tripId, originStationId, destinationStationId); + + for (const seatId of seatIds) { + const overlaps = await checkOverlappingReservations(tx, tripId, seatId, segments); + if (overlaps.length > 0) { + throw new Error(`Seat ${seatId} has overlapping reservations`); + } + } + + // 3. Create hold record + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + const seatHold = await tx.seatHold.create({ + data: { + tripId, + seatIds, + passengerId, + expiresAt + } + }); + + // 4. Update seat status to HELD + await tx.seat.updateMany({ + where: { id: { in: seatIds } }, + data: { + status: 'HELD', + heldUntil: expiresAt + } + }); + + console.log(' → Seats held successfully'); + return { + holdId: seatHold.id, + expiresAt, + segments: segments.length, + seats: seatIds.length + }; + }); +} + +async function confirmBookingTransaction(holdId: string, bookingId: string, segments: any[]) { + return prisma.$transaction(async (tx) => { + console.log(' → Starting booking confirmation transaction...'); + + // 1. Validate hold + const hold = await tx.seatHold.findUnique({ where: { id: holdId } }); + if (!hold || hold.expiresAt < new Date()) { + throw new Error('Hold expired or not found'); + } + + // 2. Create booking record (simplified) + const booking = await tx.booking.create({ + data: { + id: bookingId, + bookingRef: 'BK' + Date.now().toString().slice(-6), + passengerId: hold.passengerId, + tripId: hold.tripId, + status: 'CONFIRMED', + totalMinor: 45000, // Example fare + currency: 'ETB' + } + }); + + // 3. Create journey record + const journey = await tx.journey.create({ + data: { + passengerId: hold.passengerId, + status: 'CONFIRMED', + totalMinor: 45000, + currency: 'ETB' + } + }); + + // 4. Create journey segments for each seat + for (const seatId of hold.seatIds) { + for (let i = 0; i < segments.length; i++) { + await tx.journeySegment.create({ + data: { + journeyId: journey.id, + tripId: hold.tripId, + segmentOrder: i + 1, + seatId, + departureStationId: segments[i].fromStationId, + arrivalStationId: segments[i].toStationId + } + }); + } + } + + // 5. Create booking seats + for (const seatId of hold.seatIds) { + await tx.bookingSeat.create({ + data: { + bookingId, + seatId, + passengerName: 'Kelemu Ketsela' // Example + } + }); + } + + // 6. Update seat status to BOOKED + await tx.seat.updateMany({ + where: { id: { in: hold.seatIds } }, + data: { + status: 'BOOKED', + heldUntil: null + } + }); + + // 7. Delete hold + await tx.seatHold.delete({ where: { id: holdId } }); + + console.log(' → Booking confirmed successfully'); + return { + bookingId, + bookingRef: booking.bookingRef, + confirmedSeats: hold.seatIds.length, + segments: segments.length + }; + }); +} + +async function simulateTripProgress(tripId: string, bookedSegments: any[]) { + console.log(' → Simulating trip progress...'); + + // Simulate train reaching each station + for (const segment of bookedSegments) { + console.log(` → Train approaching ${segment.toName}...`); + + // Update trip live status + await prisma.tripLiveStatus.upsert({ + where: { tripId }, + update: { + currentLocationLabel: segment.toName, + progressPercent: Math.round((segment.toSequence / 4) * 100), + updatedAt: new Date() + }, + create: { + tripId, + state: 'EN_ROUTE', + currentLocationLabel: segment.toName, + progressPercent: Math.round((segment.toSequence / 4) * 100), + delayMinutes: 0, + updatedAt: new Date() + } + }); + + // Check if this is the final destination for any passengers + if (segment.toName === 'Dire Dawa') { + console.log(' → Passengers reached destination, releasing seats...'); + await releaseSeatsAtStation(tripId, segment.toStationId); + } + + await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay + } +} + +async function releaseSeatsAtStation(tripId: string, stationId: string) { + return prisma.$transaction(async (tx) => { + // Find journey segments ending at this station + const completedSegments = await tx.journeySegment.findMany({ + where: { + tripId, + arrivalStationId: stationId + }, + include: { + journey: { + include: { + journeySegments: { + where: { tripId } + } + } + } + } + }); + + const seatsToRelease = []; + + // Check if passenger's entire journey is complete + for (const segment of completedSegments) { + const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId); + const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder)); + + if (segment.segmentOrder === maxOrder) { + seatsToRelease.push(segment.seatId!); + } + } + + // Release seats + if (seatsToRelease.length > 0) { + await tx.seat.updateMany({ + where: { id: { in: seatsToRelease } }, + data: { status: 'AVAILABLE' } + }); + + console.log(` → Released ${seatsToRelease.length} seats at station`); + } + + return seatsToRelease; + }); +} + +async function checkOverlappingReservations(tx: any, tripId: string, seatId: string, segments: any[]) { + // Check active holds + const activeHolds = await tx.seatHold.findMany({ + where: { + tripId, + seatIds: { has: seatId }, + expiresAt: { gt: new Date() } + } + }); + + // Check active bookings + const activeBookings = await tx.journeySegment.findMany({ + where: { + tripId, + seatId, + journey: { + status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } + } + } + }); + + return [...activeHolds, ...activeBookings]; +} + +// Example API Usage +async function exampleApiUsage() { + console.log('\n=== API ENDPOINT EXAMPLES ===\n'); + + const baseUrl = 'http://localhost:4000'; + + // 1. Check availability + console.log('GET /segments/seats/availability'); + console.log('Query: tripId=trip_001&originStationId=st_ADD&destinationStationId=st_DRE'); + console.log('Response: Available seats for Addis Ababa → Dire Dawa segments\n'); + + // 2. Hold seats + console.log('POST /segments/seats/hold'); + console.log('Body:', JSON.stringify({ + tripId: 'trip_001', + seatIds: ['seat_1', 'seat_2'], + passengerId: 'passenger_123', + originStationId: 'st_ADD', + destinationStationId: 'st_DRE' + }, null, 2)); + console.log('Response: Hold created with 10-minute expiry\n'); + + // 3. Confirm booking + console.log('POST /segments/seats/confirm'); + console.log('Body:', JSON.stringify({ + holdId: 'hold_123', + bookingId: 'booking_456' + }, null, 2)); + console.log('Response: Booking confirmed, seats reserved for segments\n'); + + // 4. Release seats (triggered by trip progress) + console.log('POST /segments/seats/release'); + console.log('Body:', JSON.stringify({ + tripId: 'trip_001', + currentStationId: 'st_DRE' + }, null, 2)); + console.log('Response: Seats released for passengers reaching Dire Dawa\n'); +} + +// Run examples +if (require.main === module) { + exampleBookingFlow() + .then(() => exampleApiUsage()) + .then(() => console.log('\n=== EXAMPLES COMPLETED ===')) + .catch(console.error) + .finally(() => prisma.$disconnect()); +} + +export { + exampleBookingFlow, + getJourneySegments, + holdSeatsTransaction, + confirmBookingTransaction, + simulateTripProgress, + releaseSeatsAtStation +}; \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts new file mode 100644 index 000000000..57b6f2d2f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -0,0 +1,370 @@ +import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { SegmentsService, Segment } from '../segments/segments.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +export interface SeatHoldRequest { + tripId: string; + seatIds: string[]; + passengerId: string; + originStationId: string; + destinationStationId: string; + fareQuoteId?: string; +} + +export interface BookingConfirmRequest { + holdId: string; + bookingId: string; +} + +@Injectable() +export class EnhancedSeatsService { + constructor( + private prisma: PrismaService, + private segmentsService: SegmentsService, + private eventEmitter: EventEmitter2 + ) {} + + /** + * Hold seats for specific segments with atomicity + */ + async holdSeats(request: SeatHoldRequest) { + return this.prisma.$transaction(async (tx) => { + // 1. Get journey segments + const segments = await this.segmentsService.getJourneySegments( + request.tripId, + request.originStationId, + request.destinationStationId + ); + + // 2. Check seat availability for all requested seats + for (const seatId of request.seatIds) { + const seat = await tx.seat.findUnique({ + where: { id: seatId }, + include: { coach: true } + }); + + if (!seat) { + throw new BadRequestException(`Seat ${seatId} not found`); + } + + if (seat.status === 'BLOCKED') { + throw new BadRequestException(`Seat ${seat.label} is blocked`); + } + + // Check for overlapping reservations + const overlaps = await this.segmentsService.getOverlappingReservations( + request.tripId, + seatId, + segments + ); + + if (overlaps.length > 0) { + throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`); + } + } + + // 3. Create seat hold + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + const seatHold = await tx.seatHold.create({ + data: { + tripId: request.tripId, + seatIds: request.seatIds, + passengerId: request.passengerId, + fareQuoteId: request.fareQuoteId, + expiresAt + } + }); + + // 4. Update seat status to HELD + await tx.seat.updateMany({ + where: { id: { in: request.seatIds } }, + data: { + status: 'HELD', + heldUntil: expiresAt + } + }); + + // 5. Emit event for real-time updates + this.eventEmitter.emit('seats.held', { + holdId: seatHold.id, + tripId: request.tripId, + seatIds: request.seatIds, + segments + }); + + return { + holdId: seatHold.id, + expiresAt, + segments, + seats: request.seatIds + }; + }); + } + + /** + * Confirm booking and convert hold to booking + */ + async confirmBooking(request: BookingConfirmRequest) { + return this.prisma.$transaction(async (tx) => { + // 1. Get and validate hold + const hold = await tx.seatHold.findUnique({ + where: { id: request.holdId } + }); + + if (!hold) { + throw new BadRequestException('Seat hold not found'); + } + + if (hold.expiresAt < new Date()) { + throw new BadRequestException('Seat hold has expired'); + } + + // 2. Get booking + const booking = await tx.booking.findUnique({ + where: { id: request.bookingId } + }); + + if (!booking) { + throw new BadRequestException('Booking not found'); + } + + // 3. Get journey segments - we need to derive from trip stops + const trip = await tx.trip.findUnique({ + where: { id: hold.tripId }, + include: { + stopTimes: { + orderBy: { sequence: 'asc' } + } + } + }); + + if (!trip) { + throw new BadRequestException('Trip not found'); + } + + // For now, create segments for the full trip (would need origin/destination from booking) + const segments = []; + for (let i = 0; i < trip.stopTimes.length - 1; i++) { + segments.push({ + fromStationId: trip.stopTimes[i].stationId, + toStationId: trip.stopTimes[i + 1].stationId, + fromSequence: trip.stopTimes[i].sequence, + toSequence: trip.stopTimes[i + 1].sequence + }); + } + + // 4. Create journey record + const journey = await tx.journey.create({ + data: { + passengerId: hold.passengerId, + status: 'CONFIRMED', + totalMinor: booking.totalMinor, + currency: booking.currency + } + }); + + // 5. Create journey segments for each seat + for (const seatId of hold.seatIds) { + for (let i = 0; i < segments.length; i++) { + await tx.journeySegment.create({ + data: { + journeyId: journey.id, + tripId: hold.tripId, + segmentOrder: i + 1, + seatId, + departureStationId: segments[i].fromStationId, + arrivalStationId: segments[i].toStationId + } + }); + } + } + + // 6. Update seat status to BOOKED + await tx.seat.updateMany({ + where: { id: { in: hold.seatIds } }, + data: { + status: 'BOOKED', + heldUntil: null + } + }); + + // 7. Delete the hold + await tx.seatHold.delete({ + where: { id: request.holdId } + }); + + // 8. Emit confirmation event + this.eventEmitter.emit('booking.confirmed', { + bookingId: request.bookingId, + tripId: hold.tripId, + seatIds: hold.seatIds, + segments + }); + + return { + bookingId: request.bookingId, + confirmedSeats: hold.seatIds, + segments + }; + }); + } + + /** + * Release seats when passenger reaches destination + */ + async releaseSeats(tripId: string, currentStationId: string) { + return this.prisma.$transaction(async (tx) => { + // 1. Find all journey segments ending at current station + const completedSegments = await tx.journeySegment.findMany({ + where: { + tripId, + arrivalStationId: currentStationId + }, + include: { + journey: { + include: { + journeySegments: { + where: { tripId } + } + } + } + } + }); + + const seatsToRelease = []; + + // 2. Check if passenger's entire journey is complete + for (const segment of completedSegments) { + const allSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId); + const maxSegmentOrder = Math.max(...allSegments.map((js: any) => js.segmentOrder)); + + // If this is the last segment for this seat, release it + if (segment.segmentOrder === maxSegmentOrder) { + seatsToRelease.push(segment.seatId!); + } + } + + // 3. Update seat status to AVAILABLE + if (seatsToRelease.length > 0) { + await tx.seat.updateMany({ + where: { id: { in: seatsToRelease } }, + data: { status: 'AVAILABLE' } + }); + + // 4. Mark journey segments as completed (optional - could add a completed field) + // For now, we'll leave the segments as they are for historical tracking + + // 5. Emit release event + this.eventEmitter.emit('seats.released', { + tripId, + stationId: currentStationId, + releasedSeats: seatsToRelease + }); + } + + return { + releasedSeats: seatsToRelease, + stationId: currentStationId + }; + }); + } + + /** + * Expire old holds (background job) + */ + async expireHolds() { + return this.prisma.$transaction(async (tx) => { + const expiredHolds = await tx.seatHold.findMany({ + where: { + expiresAt: { lt: new Date() } + } + }); + + const expiredSeatIds = expiredHolds.flatMap(hold => hold.seatIds); + + if (expiredSeatIds.length > 0) { + // Release expired seats + await tx.seat.updateMany({ + where: { id: { in: expiredSeatIds } }, + data: { + status: 'AVAILABLE', + heldUntil: null + } + }); + + // Delete expired holds + await tx.seatHold.deleteMany({ + where: { + expiresAt: { lt: new Date() } + } + }); + + this.eventEmitter.emit('holds.expired', { + expiredHolds: expiredHolds.length, + releasedSeats: expiredSeatIds + }); + } + + return { + expiredHolds: expiredHolds.length, + releasedSeats: expiredSeatIds + }; + }); + } + + /** + * Get seat availability for specific segments + */ + async getSeatAvailability(tripId: string, originStationId: string, destinationStationId: string) { + const segments = await this.segmentsService.getJourneySegments( + tripId, + originStationId, + destinationStationId + ); + + const trip = await this.prisma.trip.findUnique({ + where: { id: tripId }, + include: { + coaches: { + include: { + seats: true + } + } + } + }); + + if (!trip) { + throw new BadRequestException('Trip not found'); + } + + const availableSeats = []; + + for (const coach of trip.coaches) { + for (const seat of coach.seats) { + const overlaps = await this.segmentsService.getOverlappingReservations( + tripId, + seat.id, + segments + ); + + if (overlaps.length === 0 && seat.status === 'AVAILABLE') { + availableSeats.push({ + id: seat.id, + label: seat.label, + coach: coach.label, + serviceClass: coach.serviceClass, + row: seat.row, + col: seat.col + }); + } + } + } + + return { + segments, + availableSeats, + totalAvailable: availableSeats.length + }; + } +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/segments.controller.ts b/apps/edr-passenger-api/src/modules/segments/segments.controller.ts new file mode 100644 index 000000000..f733195f3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segments.controller.ts @@ -0,0 +1,136 @@ +import { Controller, Post, Get, Body, Query, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { EnhancedSeatsService } from './enhanced-seats.service'; +import { HoldSeatsDto, ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto'; + +@ApiTags('Segment-based Seats') +@Controller('segments/seats') +export class SegmentSeatsController { + constructor(private enhancedSeatsService: EnhancedSeatsService) {} + + @Post('hold') + @ApiOperation({ + summary: 'Hold seats for specific journey segments', + description: 'Reserve seats for a partial journey (e.g., Addis Ababa → Dire Dawa) with 10-minute expiry' + }) + @ApiResponse({ + status: 201, + description: 'Seats held successfully', + schema: { + example: { + holdId: 'hold_123', + expiresAt: '2024-01-15T10:10:00Z', + segments: [ + { fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 }, + { fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 }, + { fromName: 'Awash', toName: 'Dire Dawa', fromSequence: 2, toSequence: 3 } + ], + seats: ['seat_1', 'seat_2'] + } + } + }) + @ApiResponse({ status: 409, description: 'Seats not available for requested segments' }) + async holdSeats(@Body() dto: HoldSeatsDto) { + return this.enhancedSeatsService.holdSeats({ + tripId: dto.tripId, + seatIds: dto.seatIds, + passengerId: dto.passengerId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + fareQuoteId: dto.fareQuoteId + }); + } + + @Post('confirm') + @ApiOperation({ + summary: 'Confirm booking and convert hold to reservation', + description: 'Convert seat hold to confirmed booking after payment success' + }) + @ApiResponse({ + status: 200, + description: 'Booking confirmed successfully', + schema: { + example: { + bookingId: 'booking_123', + confirmedSeats: ['seat_1', 'seat_2'], + segments: [ + { fromName: 'Addis Ababa', toName: 'Adama' }, + { fromName: 'Adama', toName: 'Awash' }, + { fromName: 'Awash', toName: 'Dire Dawa' } + ] + } + } + }) + @ApiResponse({ status: 400, description: 'Hold expired or not found' }) + async confirmBooking(@Body() dto: ConfirmBookingDto) { + return this.enhancedSeatsService.confirmBooking(dto); + } + + @Post('release') + @ApiOperation({ + summary: 'Release seats when train reaches station', + description: 'Automatically release seats for passengers who have reached their destination' + }) + @ApiResponse({ + status: 200, + description: 'Seats released successfully', + schema: { + example: { + releasedSeats: ['seat_1', 'seat_2'], + stationId: 'st_DRE' + } + } + }) + async releaseSeats(@Body() dto: ReleaseSeatsDto) { + return this.enhancedSeatsService.releaseSeats(dto.tripId, dto.currentStationId); + } + + @Get('availability') + @ApiOperation({ + summary: 'Check seat availability for journey segments', + description: 'Get available seats for a specific origin-destination pair' + }) + @ApiResponse({ + status: 200, + description: 'Seat availability retrieved', + schema: { + example: { + segments: [ + { fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 }, + { fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 } + ], + availableSeats: [ + { id: 'seat_1', label: '1A', coach: 'A', serviceClass: 'ECONOMY', row: 1, col: 'A' }, + { id: 'seat_2', label: '1B', coach: 'A', serviceClass: 'ECONOMY', row: 1, col: 'B' } + ], + totalAvailable: 2 + } + } + }) + async getSeatAvailability(@Query() dto: SeatAvailabilityDto) { + return this.enhancedSeatsService.getSeatAvailability( + dto.tripId, + dto.originStationId, + dto.destinationStationId + ); + } + + @Post('expire-holds') + @ApiOperation({ + summary: 'Expire old seat holds (background job)', + description: 'Release seats from expired holds and make them available' + }) + @ApiResponse({ + status: 200, + description: 'Expired holds processed', + schema: { + example: { + expiredHolds: 5, + releasedSeats: ['seat_1', 'seat_2', 'seat_3'] + } + } + }) + async expireHolds() { + return this.enhancedSeatsService.expireHolds(); + } +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/segments.dto.ts b/apps/edr-passenger-api/src/modules/segments/segments.dto.ts new file mode 100644 index 000000000..b04ccf284 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segments.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsArray, IsOptional } from 'class-validator'; + +export class HoldSeatsDto { + @ApiProperty({ example: 'trip_123' }) + @IsString() + tripId: string; + + @ApiProperty({ example: ['seat_1', 'seat_2'] }) + @IsArray() + @IsString({ each: true }) + seatIds: string[]; + + @ApiProperty({ example: 'passenger_123' }) + @IsString() + passengerId: string; + + @ApiProperty({ example: 'st_ADD' }) + @IsString() + originStationId: string; + + @ApiProperty({ example: 'st_DRE' }) + @IsString() + destinationStationId: string; + + @ApiProperty({ example: 'quote_123', required: false }) + @IsOptional() + @IsString() + fareQuoteId?: string; +} + +export class ConfirmBookingDto { + @ApiProperty({ example: 'hold_123' }) + @IsString() + holdId: string; + + @ApiProperty({ example: 'booking_123' }) + @IsString() + bookingId: string; +} + +export class SeatAvailabilityDto { + @ApiProperty({ example: 'trip_123' }) + @IsString() + tripId: string; + + @ApiProperty({ example: 'st_ADD' }) + @IsString() + originStationId: string; + + @ApiProperty({ example: 'st_DRE' }) + @IsString() + destinationStationId: string; +} + +export class ReleaseSeatsDto { + @ApiProperty({ example: 'trip_123' }) + @IsString() + tripId: string; + + @ApiProperty({ example: 'st_DRE' }) + @IsString() + currentStationId: string; +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/segments.module.ts b/apps/edr-passenger-api/src/modules/segments/segments.module.ts new file mode 100644 index 000000000..f82da7fa9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segments.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { SegmentsService } from './segments.service'; +import { EnhancedSeatsService } from './enhanced-seats.service'; +import { TripProgressService } from './trip-progress.service'; +import { SegmentSeatsController } from './segments.controller'; +import { PrismaService } from '../../common/prisma.service'; + +@Module({ + controllers: [SegmentSeatsController], + providers: [ + SegmentsService, + EnhancedSeatsService, + TripProgressService, + PrismaService + ], + exports: [ + SegmentsService, + EnhancedSeatsService, + TripProgressService + ] +}) +export class SegmentsModule {} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts new file mode 100644 index 000000000..9346e25b3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -0,0 +1,144 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; + +export interface Segment { + fromStationId: string; + toStationId: string; + fromSequence: number; + toSequence: number; + fromName: string; + toName: string; +} + +@Injectable() +export class SegmentsService { + constructor(private prisma: PrismaService) {} + + /** + * Derive all segments between origin and destination using TripStopTime.sequence + * Example: Addis → Dire Dawa = [Addis → Adama, Adama → Awash, Awash → Dire Dawa] + */ + async getJourneySegments(tripId: string, originStationId: string, destinationStationId: string): Promise { + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { tripId }, + include: { station: true }, + orderBy: { sequence: 'asc' } + }); + + const originStop = stopTimes.find(st => st.stationId === originStationId); + const destinationStop = stopTimes.find(st => st.stationId === destinationStationId); + + if (!originStop || !destinationStop) { + throw new BadRequestException('Origin or destination station not found on this trip'); + } + + if (originStop.sequence >= destinationStop.sequence) { + throw new BadRequestException('Origin must come before destination'); + } + + const segments: Segment[] = []; + for (let i = originStop.sequence; i < destinationStop.sequence; i++) { + const fromStop = stopTimes.find(st => st.sequence === i); + const toStop = stopTimes.find(st => st.sequence === i + 1); + + if (fromStop && toStop) { + segments.push({ + fromStationId: fromStop.stationId, + toStationId: toStop.stationId, + fromSequence: fromStop.sequence, + toSequence: toStop.sequence, + fromName: fromStop.station.name, + toName: toStop.station.name + }); + } + } + + return segments; + } + + /** + * Check if two segment ranges overlap + */ + segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean { + for (const seg1 of segments1) { + for (const seg2 of segments2) { + // Segments overlap if one starts before the other ends + if (seg1.fromSequence < seg2.toSequence && seg2.fromSequence < seg1.toSequence) { + return true; + } + } + } + return false; + } + + /** + * Get all existing bookings/holds that overlap with given segments + */ + async getOverlappingReservations(tripId: string, seatId: string, segments: Segment[]) { + // Get active holds + const activeHolds = await this.prisma.seatHold.findMany({ + where: { + tripId, + seatIds: { has: seatId }, + expiresAt: { gt: new Date() } + } + }); + + // Get active bookings with journey segments + const activeBookings = await this.prisma.bookingSeat.findMany({ + where: { + seatId, + booking: { + tripId, + status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } + } + }, + include: { + booking: true + } + }); + + const overlaps = []; + + // Check hold overlaps (assume full journey for holds) + for (const hold of activeHolds) { + overlaps.push({ type: 'hold', id: hold.id }); + } + + // Check booking overlaps by querying journey segments separately + for (const booking of activeBookings) { + const journeySegments = await this.prisma.journeySegment.findMany({ + where: { + tripId, + seatId, + journeyId: booking.bookingId + } + }); + + for (const journeySegment of journeySegments) { + // Get sequence numbers for this segment + const segmentStops = await this.prisma.tripStopTime.findMany({ + where: { + tripId, + stationId: { in: [journeySegment.departureStationId, journeySegment.arrivalStationId] } + } + }); + + const fromSeq = segmentStops.find(s => s.stationId === journeySegment.departureStationId)?.sequence; + const toSeq = segmentStops.find(s => s.stationId === journeySegment.arrivalStationId)?.sequence; + + if (fromSeq !== undefined && toSeq !== undefined) { + // Check if any requested segment overlaps with this booking segment + for (const reqSeg of segments) { + if (reqSeg.fromSequence < toSeq && fromSeq < reqSeg.toSequence) { + overlaps.push({ type: 'booking', id: booking.booking.id }); + break; + } + } + } + } + } + + return overlaps; + } +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts b/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts new file mode 100644 index 000000000..9bd974dac --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts @@ -0,0 +1,220 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { EnhancedSeatsService } from './enhanced-seats.service'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +@Injectable() +export class TripProgressService { + constructor( + private prisma: PrismaService, + private enhancedSeatsService: EnhancedSeatsService, + private eventEmitter: EventEmitter2 + ) {} + + /** + * Update trip progress and trigger seat releases + */ + async updateTripProgress(tripId: string, currentStationId: string, progressPercent: number) { + return this.prisma.$transaction(async (tx) => { + // 1. Update trip live status + await tx.tripLiveStatus.upsert({ + where: { tripId }, + update: { + currentLocationLabel: currentStationId, + progressPercent, + updatedAt: new Date() + }, + create: { + tripId, + state: 'EN_ROUTE', + currentLocationLabel: currentStationId, + progressPercent, + delayMinutes: 0, + updatedAt: new Date() + } + }); + + // 2. Get station name for comparison + const station = await tx.station.findUnique({ + where: { id: currentStationId } + }); + + if (station) { + // 3. Trigger seat release for passengers reaching destination + const releaseResult = await this.enhancedSeatsService.releaseSeats(tripId, currentStationId); + + // 4. Emit progress update event + this.eventEmitter.emit('trip.progress.updated', { + tripId, + currentStation: station.name, + progressPercent, + releasedSeats: releaseResult.releasedSeats + }); + + return { + tripId, + currentStation: station.name, + progressPercent, + releasedSeats: releaseResult.releasedSeats.length, + updatedAt: new Date() + }; + } + + return { tripId, currentStation: currentStationId, progressPercent, releasedSeats: 0 }; + }); + } + + /** + * Simulate trip progress (for testing/demo) + */ + async simulateTripProgress(tripId: string) { + const trip = await this.prisma.trip.findUnique({ + where: { id: tripId }, + include: { + stopTimes: { + include: { station: true }, + orderBy: { sequence: 'asc' } + } + } + }); + + if (!trip) { + throw new Error('Trip not found'); + } + + // Simulate progress through each station + for (let i = 0; i < trip.stopTimes.length; i++) { + const stopTime = trip.stopTimes[i]; + const progressPercent = Math.round((i / (trip.stopTimes.length - 1)) * 100); + + await this.updateTripProgress(tripId, stopTime.stationId, progressPercent); + + // Emit station arrival event + this.eventEmitter.emit('trip.station.arrived', { + tripId, + stationId: stopTime.stationId, + stationName: stopTime.station.name, + sequence: stopTime.sequence, + progressPercent + }); + + // Wait 30 seconds between stations (for demo) + await new Promise(resolve => setTimeout(resolve, 30000)); + } + } + + /** + * Handle trip completion + */ + @OnEvent('trip.completed') + async handleTripCompleted(payload: { tripId: string }) { + // Release all remaining seats for this trip + const trip = await this.prisma.trip.findUnique({ + where: { id: payload.tripId }, + include: { + coaches: { + include: { + seats: { + where: { status: 'BOOKED' } + } + } + } + } + }); + + if (trip) { + const bookedSeatIds = trip.coaches.flatMap(coach => + coach.seats.map(seat => seat.id) + ); + + if (bookedSeatIds.length > 0) { + await this.prisma.seat.updateMany({ + where: { id: { in: bookedSeatIds } }, + data: { status: 'AVAILABLE' } + }); + + this.eventEmitter.emit('trip.seats.released', { + tripId: payload.tripId, + releasedSeats: bookedSeatIds + }); + } + } + } + + /** + * Background job to expire holds every minute + */ + @Cron(CronExpression.EVERY_MINUTE) + async expireHoldsJob() { + try { + const result = await this.enhancedSeatsService.expireHolds(); + if (result.expiredHolds > 0) { + console.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`); + } + } catch (error) { + console.error('Error expiring holds:', error); + } + } + + /** + * Get current trip status with seat availability + */ + async getTripStatus(tripId: string) { + const trip = await this.prisma.trip.findUnique({ + where: { id: tripId }, + include: { + liveStatus: true, + stopTimes: { + include: { station: true }, + orderBy: { sequence: 'asc' } + }, + coaches: { + include: { + seats: true + } + } + } + }); + + if (!trip) { + throw new Error('Trip not found'); + } + + const seatSummary = { + total: 0, + available: 0, + held: 0, + booked: 0, + blocked: 0 + }; + + trip.coaches.forEach(coach => { + coach.seats.forEach(seat => { + seatSummary.total++; + const status = seat.status.toLowerCase() as keyof typeof seatSummary; + if (status in seatSummary) { + seatSummary[status]++; + } + }); + }); + + return { + tripId, + status: trip.status, + currentLocation: trip.liveStatus?.currentLocationLabel, + progressPercent: trip.liveStatus?.progressPercent || 0, + delayMinutes: trip.liveStatus?.delayMinutes || 0, + stations: trip.stopTimes.map(st => ({ + id: st.stationId, + name: st.station.name, + sequence: st.sequence, + plannedArrival: st.plannedArrivalAt, + plannedDeparture: st.plannedDepartureAt, + actualArrival: st.actualArrivalAt + })), + seatSummary, + lastUpdated: trip.liveStatus?.updatedAt + }; + } +} \ No newline at end of file diff --git a/apps/edr-passenger-web/portal/.env.example b/apps/edr-passenger-web/portal/.env.example index 34eff7170..1fad0847d 100644 --- a/apps/edr-passenger-web/portal/.env.example +++ b/apps/edr-passenger-web/portal/.env.example @@ -1 +1 @@ -VITE_API_URL=http://localhost:3002 +VITE_API_URL=http://localhost:4000 diff --git a/apps/edr-passenger-web/portal/src/services/stations.service.ts b/apps/edr-passenger-web/portal/src/services/stations.service.ts index 542ae0e32..1c1b4f4d2 100644 --- a/apps/edr-passenger-web/portal/src/services/stations.service.ts +++ b/apps/edr-passenger-web/portal/src/services/stations.service.ts @@ -1,10 +1,13 @@ -import type { Passenger } from "@edr/types"; - +import type { IStation } from "../types"; import { api } from "../utils/api"; export const stationsService = { - list: async (): Promise => { + list: async (): Promise => { const { data } = await api.get("/stations"); return data.data; }, + get: async (id: string): Promise => { + const { data } = await api.get(`/stations/${id}`); + return data.data; + }, }; diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index ac3b23830..09b7c9fc2 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -1,4 +1,266 @@ -export type { Passenger } from "@edr/types"; +// ── Enums ────────────────────────────────────────────────────────────────────── +export type TripStatus = 'SCHEDULED' | 'BOARDING' | 'EN_ROUTE' | 'ARRIVED' | 'CANCELLED' | 'DELAYED'; +export type SeatStatus = 'AVAILABLE' | 'HELD' | 'BOOKED' | 'BLOCKED'; +export type ServiceClass = 'ECONOMY' | 'BUSINESS' | 'FIRST'; +export type BookingStatus = 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'; +export type PaymentMethod = 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'CARD' | 'WALLET'; + +// ── Station ──────────────────────────────────────────────────────────────────── +export interface IStation { + id: string; + code: string; + name: string; + city: string; + timezone: string; + lat: number; + lng: number; +} + +// ── Trip / Schedule ──────────────────────────────────────────────────────────── +export interface ITripStation { + id: string; + code: string; + name: string; + city: string; +} + +export interface ITrip { + id: string; + number: string; + origin: ITripStation; + destination: ITripStation; + departureAt: string; + arrivalAt: string; + status: TripStatus; + availability: { ECONOMY: number; BUSINESS: number; FIRST: number }; + fares: { ECONOMY: number; BUSINESS: number; FIRST: number }; +} + +export interface ITripDetail { + id: string; + serviceId: string; + originStationId: string; + destinationStationId: string; + departureAt: string; + arrivalAt: string; + durationMinutes: number; + status: TripStatus; + service: { id: string; number: string; name: string }; + originStation: IStation; + destinationStation: IStation; + coaches: ICoach[]; + stopTimes: IStopTime[]; +} + +export interface IStopTime { + id: string; + sequence: number; + plannedArrivalAt: string | null; + plannedDepartureAt: string | null; + actualArrivalAt: string | null; + status: string; + station: IStation; +} + +// ── Seat ─────────────────────────────────────────────────────────────────────── +export interface ISeat { + id: string; + number: string; // label from API + status: SeatStatus; + kind: string; +} + +export interface ICoach { + id: string; + name: string; + type: ServiceClass; + seats: ISeat[]; +} + +export interface ISeatMap { + coaches: ICoach[]; +} + +// ── Segment-based seats ──────────────────────────────────────────────────────── +export interface ISegment { + fromStationId: string; + toStationId: string; + fromSequence: number; + toSequence: number; + fromName: string; + toName: string; +} + +export interface ISegmentSeat { + id: string; + label: string; + coach: string; + serviceClass: ServiceClass; + row: number; + col: string; +} + +export interface ISegmentAvailability { + segments: ISegment[]; + availableSeats: ISegmentSeat[]; + totalAvailable: number; +} + +export interface ISegmentHoldResult { + holdId: string; + expiresAt: string; + segments: ISegment[]; + seats: string[]; +} + +export interface ISegmentConfirmResult { + bookingId: string; + confirmedSeats: string[]; + segments: Array<{ fromStationId: string; toStationId: string; fromSequence: number; toSequence: number }>; +} + +// ── Booking ──────────────────────────────────────────────────────────────────── +export interface IBooking { + id: string; + bookingRef: string; + status: BookingStatus; + totalFare: number; + createdAt: string; + trip: { + number: string; + origin: ITripStation; + destination: ITripStation; + departureAt: string; + arrivalAt: string; + }; + passengers: Array<{ + fullName: string; + seat: { number: string; coach: string; class: ServiceClass }; + }>; + payment?: { method: PaymentMethod; status: string }; +} + +// ── Ticket ───────────────────────────────────────────────────────────────────── +export interface ITicket { + id: string; + bookingId: string; + bookingRef: string; + status: string; + fromStationName: string; + toStationName: string; + departureAt: string; + trainName: string; + coachLabel: string; + seatLabel: string; + passengerName: string; + priceMinor: number; + currency: string; + qrPayload: string; +} + +// ── Fare quote ───────────────────────────────────────────────────────────────── +export interface IFareQuote { + tripId: string; + serviceClass: ServiceClass; + passengerCount: number; + baseFareMinor: number; + discountMinor: number; + loyaltyRedemptionMinor: number; + taxesFeesMinor: number; + totalMinor: number; + currency: string; +} + +// ── Passenger ───────────────────────────────────────────────────────────────── +export interface IPassengerProfile { + id: string; + fullName: string; + email: string; + phone: string; + createdAt: string; + bookings: IBooking[]; +} + +// ── Loyalty ──────────────────────────────────────────────────────────────────── +export interface ILoyaltyAccount { + id: string; + passengerId: string; + pointsBalance: number; + tier: 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM'; + nextTier: string | null; + pointsToNextTier: number; + tierProgressPercent: number; +} + +// ── Wallet ───────────────────────────────────────────────────────────────────── +export interface IWallet { + id: string; + passengerId: string; + balanceMinor: number; + currency: string; +} + +// ── Notification ─────────────────────────────────────────────────────────────── +export interface INotification { + id: string; + passengerId: string; + title: string; + body: string; + category: string; + read: boolean; + deepLink?: string; + createdAt: string; +} + +// ── Promotion ────────────────────────────────────────────────────────────────── +export interface IPromotion { + id: string; + title: string; + subtitle?: string; + code: string; + percentOff?: number; + amountOffMinor?: number; + validUntil: string; + ctaLabel?: string; + active: boolean; +} + +// ── Live tracking ────────────────────────────────────────────────────────────── +export interface ILiveStatus { + tripId: string; + trainName: string; + fromStationName: string; + toStationName: string; + state: string; + currentLocationLabel?: string; + progressPercent: number; + delayMinutes: number; + currentSpeedKph?: number; + platformLabel?: string; + nextStopStationName?: string; + updatedAt: string; +} + +// ── Dashboard ────────────────────────────────────────────────────────────────── +export interface IDashboard { + user: { firstName: string; greetingKey: 'MORNING' | 'AFTERNOON' | 'EVENING' }; + upcomingTicket: { + ticketId?: string; + bookingRef: string; + from: string; + to: string; + trainName: string; + coachLabel?: string; + seatLabel?: string; + departureAt: string; + punctualityLabel: 'ON_TIME' | 'DELAYED'; + } | null; + wallet: { balanceMinor: number; currency: string } | null; + activePromotionsCount: number; + weatherAlerts: Array<{ id: string; title: string; message: string; severity: string }>; + stationSignals: Array<{ stationId: string; stationName: string; level: string; statusLabel: string }>; + savedRoutes: Array<{ id: string; fromName: string; toName: string; tripCount: number }>; +} export interface NavItem { href: string;