mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
Introduction of segment-based seat reservation
This commit is contained in:
364
apps/edr-passenger-api/SEGMENT_BASED_SEATS.md
Normal file
364
apps/edr-passenger-api/SEGMENT_BASED_SEATS.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
64
apps/edr-passenger-api/src/modules/segments/segments.dto.ts
Normal file
64
apps/edr-passenger-api/src/modules/segments/segments.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
144
apps/edr-passenger-api/src/modules/segments/segments.service.ts
Normal file
144
apps/edr-passenger-api/src/modules/segments/segments.service.ts
Normal file
@@ -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<Segment[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
VITE_API_URL=http://localhost:3002
|
||||
VITE_API_URL=http://localhost:4000
|
||||
|
||||
@@ -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<Passenger.IStation[]> => {
|
||||
list: async (): Promise<IStation[]> => {
|
||||
const { data } = await api.get("/stations");
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<IStation> => {
|
||||
const { data } = await api.get(`/stations/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user