Introduction of segment-based seat reservation

This commit is contained in:
Stephanos A
2026-05-17 11:11:39 +03:00
parent 39ba561d8f
commit 071a57a668
14 changed files with 2042 additions and 8 deletions

View File

@@ -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
};

View File

@@ -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
};
}
}

View File

@@ -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();
}
}

View 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;
}

View File

@@ -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 {}

View 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;
}
}

View File

@@ -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
};
}
}