Refactor business logic for train,schedule,coach,seat and search modules

This commit is contained in:
Roba Boru
2026-05-22 14:47:38 +03:00
parent 9151110fd8
commit 096c717bfa
48 changed files with 2254 additions and 2865 deletions

View File

@@ -1,64 +1,56 @@
/**
* SEGMENT-BASED SEAT RESERVATION EXAMPLE
*
* This example demonstrates the complete flow for booking Addis Ababa → Dire Dawa
*
* 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)
*
* Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6)
* Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4)
*/
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 scheduleId = 'schedule_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
const originStationId = 'st_ADD';
const destinationStationId = 'st_DRE';
try {
// Step 1: Check seat availability for segments
console.log('1. Checking seat availability...');
const segments = await getJourneySegments(tripId, originStationId, destinationStationId);
const segments = await getJourneySegments(scheduleId, 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);
const holdResult = await holdSeatsTransaction(scheduleId, 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);
await simulateTripProgress(scheduleId, segments);
} catch (error) {
console.error('Booking flow error:', error);
}
}
// Database Transaction Functions
async function getJourneySegments(tripId: string, originStationId: string, destinationStationId: string) {
async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) {
const stopTimes = await prisma.tripStopTime.findMany({
where: { tripId },
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' }
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
@@ -72,7 +64,6 @@ async function getJourneySegments(tripId: string, originStationId: string, desti
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,
@@ -80,27 +71,19 @@ async function getJourneySegments(tripId: string, originStationId: string, desti
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name
toName: toStop.station.name,
});
}
}
return segments;
}
async function holdSeatsTransaction(tripId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) {
async function holdSeatsTransaction(scheduleId: 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');
}
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') {
@@ -108,43 +91,15 @@ async function holdSeatsTransaction(tripId: string, seatIds: string[], passenger
}
}
// 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 expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const seatHold = await tx.seatHold.create({
data: {
tripId,
seatIds,
passengerId,
expiresAt
}
data: { scheduleId, seatIds, passengerId, expiresAt },
});
// 4. Update seat status to HELD
await tx.seat.updateMany({
where: { id: { in: seatIds } },
data: {
status: 'HELD',
heldUntil: expiresAt
}
});
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
};
return { holdId: seatHold.id, expiresAt, seats: seatIds.length };
});
}
@@ -152,157 +107,96 @@ async function confirmBookingTransaction(holdId: string, bookingId: string, segm
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');
}
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,
scheduleId: hold.scheduleId,
status: 'CONFIRMED',
totalMinor: 45000,
currency: 'ETB'
}
currency: 'ETB',
},
});
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,
scheduleId: hold.scheduleId,
segmentOrder: i + 1,
seatId,
departureStationId: segments[i].fromStationId,
arrivalStationId: segments[i].toStationId
}
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
}
});
await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } });
}
// 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.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
await tx.seatHold.delete({ where: { id: holdId } });
console.log(' → Booking confirmed successfully');
return {
bookingId,
bookingRef: booking.bookingRef,
confirmedSeats: hold.seatIds.length,
segments: segments.length
};
return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length };
});
}
async function simulateTripProgress(tripId: string, bookedSegments: any[]) {
async function simulateTripProgress(scheduleId: 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()
},
where: { scheduleId },
update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) },
create: {
tripId,
scheduleId,
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 releaseSeatsAtStation(scheduleId, segment.toStationId);
}
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
async function releaseSeatsAtStation(tripId: string, stationId: string) {
async function releaseSeatsAtStation(scheduleId: 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 }
}
}
}
}
where: { scheduleId, arrivalStationId: stationId },
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
});
const seatsToRelease = [];
const seatsToRelease: string[] = [];
// 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!);
}
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' }
});
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
console.log(` → Released ${seatsToRelease.length} seats at station`);
}
@@ -310,73 +204,24 @@ async function releaseSeatsAtStation(tripId: string, stationId: string) {
});
}
async function checkOverlappingReservations(tx: any, tripId: string, seatId: string, segments: any[]) {
// Check active holds
async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) {
const activeHolds = await tx.seatHold.findMany({
where: {
tripId,
seatIds: { has: seatId },
expiresAt: { gt: new Date() }
}
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
// Check active bookings
const activeBookings = await tx.journeySegment.findMany({
where: {
tripId,
scheduleId,
seatId,
journey: {
status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] }
}
}
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());
@@ -388,5 +233,6 @@ export {
holdSeatsTransaction,
confirmBookingTransaction,
simulateTripProgress,
releaseSeatsAtStation
};
releaseSeatsAtStation,
checkOverlappingReservations,
};

View File

@@ -4,7 +4,7 @@ import { SegmentsService, Segment } from '../segments/segments.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
export interface SeatHoldRequest {
tripId: string;
scheduleId: string;
seatIds: string[];
passengerId: string;
originStationId: string;
@@ -22,349 +22,177 @@ export class EnhancedSeatsService {
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
private eventEmitter: EventEmitter2
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
);
const segments = await this.segmentsService.getJourneySegments(request.scheduleId, 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`);
}
const seat = await tx.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
const overlaps = await this.segmentsService.getOverlappingReservations(request.scheduleId, 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 expiresAt = new Date(Date.now() + 10 * 60 * 1000);
// Encode origin/destination into fareQuoteId so confirmBooking can resolve the leg range
// Format: "leg:{originStationId}:{destinationStationId}" (or preserve actual fareQuoteId)
const legKey = request.fareQuoteId ?? `leg:${request.originStationId}:${request.destinationStationId}`;
const seatHold = await tx.seatHold.create({
data: {
tripId: request.tripId,
seatIds: request.seatIds,
passengerId: request.passengerId,
fareQuoteId: request.fareQuoteId,
expiresAt
}
data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, fareQuoteId: legKey, expiresAt },
});
// 4. Update seat status to HELD
await tx.seat.updateMany({
where: { id: { in: request.seatIds } },
data: {
status: 'HELD',
heldUntil: expiresAt
}
});
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
});
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return {
holdId: seatHold.id,
expiresAt,
segments,
seats: request.seatIds
};
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 }
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');
const booking = await tx.booking.findUnique({ where: { id: request.bookingId } });
if (!booking) throw new BadRequestException('Booking not found');
const schedule = await tx.trainSchedule.findUnique({
where: { id: hold.scheduleId },
include: { stopTimes: { orderBy: { sequence: 'asc' } } },
});
if (!schedule) throw new BadRequestException('Schedule not found');
if (!hold) {
throw new BadRequestException('Seat hold not found');
// Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId")
const legKey = hold.fareQuoteId ?? '';
let originStationId: string | undefined;
let destinationStationId: string | undefined;
if (legKey.startsWith('leg:')) {
const parts = legKey.split(':');
originStationId = parts[1];
destinationStationId = parts[2];
} else {
// Fall back to booking's own origin/destination if available
originStationId = (booking as any).originStationId;
destinationStationId = (booking as any).destinationStationId;
}
if (hold.expiresAt < new Date()) {
throw new BadRequestException('Seat hold has expired');
}
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
// 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' }
}
const segments: Segment[] = [];
for (let i = fromSeq; i < toSeq; i++) {
const fromStop = schedule.stopTimes.find(s => s.sequence === i);
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
toStationId: toStop.stationId,
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: '',
toName: '',
});
}
});
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
}
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,
scheduleId: hold.scheduleId,
segmentOrder: i + 1,
seatId,
departureStationId: segments[i].fromStationId,
arrivalStationId: segments[i].toStationId
}
arrivalStationId: segments[i].toStationId,
},
});
}
}
// 6. Update seat status to BOOKED
await tx.seat.updateMany({
where: { id: { in: hold.seatIds } },
data: {
status: 'BOOKED',
heldUntil: null
}
});
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
await tx.seatHold.delete({ where: { id: request.holdId } });
// 7. Delete the hold
await tx.seatHold.delete({
where: { id: request.holdId }
});
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
// 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
};
return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments };
});
}
/**
* Release seats when passenger reaches destination
*/
async releaseSeats(tripId: string, currentStationId: string) {
async releaseSeats(scheduleId: 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 }
}
}
}
}
where: { scheduleId, arrivalStationId: currentStationId },
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
});
const seatsToRelease = [];
// 2. Check if passenger's entire journey is complete
const seatsToRelease: string[] = [];
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!);
}
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
});
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
this.eventEmitter.emit('seats.released', { scheduleId, stationId: currentStationId, releasedSeats: seatsToRelease });
}
return {
releasedSeats: seatsToRelease,
stationId: currentStationId
};
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);
const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
const expiredSeatIds = expiredHolds.flatMap(h => h.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
});
await tx.seat.updateMany({ where: { id: { in: expiredSeatIds } }, data: { status: 'AVAILABLE', heldUntil: null } });
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
};
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
);
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId);
const trip = await this.prisma.trip.findUnique({
where: { id: tripId },
include: {
coaches: {
include: {
seats: true
}
}
}
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } },
});
if (!trip) {
throw new BadRequestException('Trip not found');
}
if (!schedule) throw new BadRequestException('Schedule 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
);
for (const assignment of schedule.coachAssignments) {
for (const seat of assignment.coach.seats) {
const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, 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
id: seat.id, label: seat.label,
coach: assignment.coach.label,
seatClass: assignment.coach.seatClass.name,
row: seat.row, col: seat.col,
});
}
}
}
return {
segments,
availableSeats,
totalAvailable: availableSeats.length
};
return { segments, availableSeats, totalAvailable: availableSeats.length };
}
}
}

View File

@@ -32,12 +32,12 @@ export class SegmentSeatsController {
@ApiResponse({ status: 409, description: 'Seats not available for requested segments' })
async holdSeats(@Body() dto: HoldSeatsDto) {
return this.enhancedSeatsService.holdSeats({
tripId: dto.tripId,
scheduleId: dto.scheduleId,
seatIds: dto.seatIds,
passengerId: dto.passengerId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
fareQuoteId: dto.fareQuoteId
fareQuoteId: dto.fareQuoteId,
});
}
@@ -82,7 +82,7 @@ export class SegmentSeatsController {
}
})
async releaseSeats(@Body() dto: ReleaseSeatsDto) {
return this.enhancedSeatsService.releaseSeats(dto.tripId, dto.currentStationId);
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
}
@Get('availability')
@@ -100,19 +100,15 @@ export class SegmentSeatsController {
{ 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' }
{ id: 'seat_1', label: '1A', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'A' },
{ id: 'seat_2', label: '1B', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'B' }
],
totalAvailable: 2
}
}
})
async getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
return this.enhancedSeatsService.getSeatAvailability(
dto.tripId,
dto.originStationId,
dto.destinationStationId
);
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
}
@Post('expire-holds')

View File

@@ -1,64 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } 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;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: ['seat_1', 'seat_2'] }) @IsArray() @IsString({ each: true }) seatIds: string[];
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
@ApiPropertyOptional({ example: 'quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;
}
export class ConfirmBookingDto {
@ApiProperty({ example: 'hold_123' })
@IsString()
holdId: string;
@ApiProperty({ example: 'booking_123' })
@IsString()
bookingId: string;
@ApiProperty({ example: 'hold-uuid' }) @IsString() holdId: string;
@ApiProperty({ example: 'booking-uuid' }) @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;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
}
export class ReleaseSeatsDto {
@ApiProperty({ example: 'trip_123' })
@IsString()
tripId: string;
@ApiProperty({ example: 'st_DRE' })
@IsString()
currentStationId: string;
}
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() currentStationId: string;
}

View File

@@ -14,33 +14,31 @@ export interface Segment {
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[]> {
async getJourneySegments(
scheduleId: string,
originStationId: string,
destinationStationId: string,
): Promise<Segment[]> {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { tripId },
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' }
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
const destinationStop = stopTimes.find(st => st.stationId === destinationStationId);
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
if (!originStop || !destinationStop) {
throw new BadRequestException('Origin or destination station not found on this trip');
if (!originStop || !destStop) {
throw new BadRequestException('Origin or destination station not found on this schedule');
}
if (originStop.sequence >= destinationStop.sequence) {
if (originStop.sequence >= destStop.sequence) {
throw new BadRequestException('Origin must come before destination');
}
const segments: Segment[] = [];
for (let i = originStop.sequence; i < destinationStop.sequence; i++) {
for (let i = originStop.sequence; i < destStop.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,
@@ -48,97 +46,85 @@ export class SegmentsService {
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name
toName: toStop.station.name,
});
}
}
return segments;
}
/**
* Check if two segment ranges overlap
*/
/** True if two segment ranges overlap: [a.from, a.to) ∩ [b.from, b.to) ≠ ∅ */
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;
}
for (const s1 of segments1) {
for (const s2 of segments2) {
if (s1.fromSequence < s2.toSequence && s2.fromSequence < s1.toSequence) return true;
}
}
return false;
}
/**
* Get all existing bookings/holds that overlap with given segments
* Returns conflicts for a seat on a schedule for the requested segment range.
* Checks:
* 1. Active SeatHolds — resolved to sequence range via JourneySegment if available,
* otherwise treated as full-schedule block.
* 2. Active BookingSeats — resolved via JourneySegment sequence ranges.
*/
async getOverlappingReservations(tripId: string, seatId: string, segments: Segment[]) {
// Get active holds
async getOverlappingReservations(
scheduleId: string,
seatId: string,
requestedSegments: Segment[],
) {
const overlaps: { type: string; id: string }[] = [];
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
// ── 1. Active holds ──────────────────────────────────────────────────────
const activeHolds = await this.prisma.seatHold.findMany({
where: {
tripId,
seatIds: { has: seatId },
expiresAt: { gt: new Date() }
}
where: { scheduleId, 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
}
// Resolve hold range from JourneySegments created at hold time
const holdSegs = await this.prisma.journeySegment.findMany({
where: { scheduleId, seatId },
include: { schedule: { include: { stopTimes: true } } },
});
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] }
}
});
if (holdSegs.length === 0) {
// No journey segments yet — conservative: treat as full-schedule conflict
overlaps.push({ type: 'hold', id: hold.id });
continue;
}
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;
}
}
for (const js of holdSegs) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
overlaps.push({ type: 'hold', id: hold.id });
break;
}
}
}
// ── 2. Active bookings via JourneySegment ────────────────────────────────
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
include: { schedule: { include: { stopTimes: true } } },
});
for (const js of bookedSegments) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
overlaps.push({ type: 'booking', id: js.journeyId });
}
}
return overlaps;
}
}
}

View File

@@ -19,14 +19,14 @@ export class TripProgressService {
return this.prisma.$transaction(async (tx) => {
// 1. Update trip live status
await tx.tripLiveStatus.upsert({
where: { tripId },
where: { scheduleId: tripId },
update: {
currentLocationLabel: currentStationId,
progressPercent,
updatedAt: new Date()
},
create: {
tripId,
scheduleId: tripId,
state: 'EN_ROUTE',
currentLocationLabel: currentStationId,
progressPercent,
@@ -69,7 +69,7 @@ export class TripProgressService {
* Simulate trip progress (for testing/demo)
*/
async simulateTripProgress(tripId: string) {
const trip = await this.prisma.trip.findUnique({
const trip = await this.prisma.trainSchedule.findUnique({
where: { id: tripId },
include: {
stopTimes: {
@@ -110,13 +110,17 @@ export class TripProgressService {
@OnEvent('trip.completed')
async handleTripCompleted(payload: { tripId: string }) {
// Release all remaining seats for this trip
const trip = await this.prisma.trip.findUnique({
const trip = await this.prisma.trainSchedule.findUnique({
where: { id: payload.tripId },
include: {
coaches: {
coachAssignments: {
include: {
seats: {
where: { status: 'BOOKED' }
coach: {
include: {
seats: {
where: { status: 'BOOKED' }
}
}
}
}
}
@@ -124,8 +128,8 @@ export class TripProgressService {
});
if (trip) {
const bookedSeatIds = trip.coaches.flatMap(coach =>
coach.seats.map(seat => seat.id)
const bookedSeatIds = trip.coachAssignments.flatMap(assignment =>
assignment.coach.seats.map(seat => seat.id)
);
if (bookedSeatIds.length > 0) {
@@ -161,7 +165,7 @@ export class TripProgressService {
* Get current trip status with seat availability
*/
async getTripStatus(tripId: string) {
const trip = await this.prisma.trip.findUnique({
const trip = await this.prisma.trainSchedule.findUnique({
where: { id: tripId },
include: {
liveStatus: true,
@@ -169,9 +173,11 @@ export class TripProgressService {
include: { station: true },
orderBy: { sequence: 'asc' }
},
coaches: {
coachAssignments: {
include: {
seats: true
coach: {
include: { seats: true }
}
}
}
}
@@ -189,8 +195,8 @@ export class TripProgressService {
blocked: 0
};
trip.coaches.forEach(coach => {
coach.seats.forEach(seat => {
trip.coachAssignments.forEach(assignment => {
assignment.coach.seats.forEach(seat => {
seatSummary.total++;
const status = seat.status.toLowerCase() as keyof typeof seatSummary;
if (status in seatSummary) {