Boarding, payment methods, journey direction on seat hold, and more updates

This commit is contained in:
Stephanos A
2026-06-29 08:44:38 +03:00
parent 81ae99cee3
commit c6e56d1c4f
65 changed files with 6437 additions and 1425 deletions

View File

@@ -33,7 +33,7 @@ export class SeatsController {
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Get seat map filtered by coach type",
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
})
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
@ApiQuery({
@@ -42,6 +42,23 @@ export class SeatsController {
description:
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
})
@ApiQuery({
name: "journeyDirection",
required: false,
enum: ['ONE_WAY', 'OUTBOUND', 'RETURN'],
description:
"Journey direction for round-trip bookings. Filters seat holds to show only conflicting holds. Use OUTBOUND for outbound leg, RETURN for return leg. Defaults to ONE_WAY (shows all holds).",
})
@ApiQuery({
name: "originStationId",
required: false,
description: "Origin station UUID for segment-specific seat availability",
})
@ApiQuery({
name: "destinationStationId",
required: false,
description: "Destination station UUID for segment-specific seat availability",
})
@ApiResponse({
status: 200,
description:
@@ -50,8 +67,17 @@ export class SeatsController {
getSeatMap(
@Param("scheduleId") scheduleId: string,
@Query("coachTypeId") coachTypeId?: string,
@Query("journeyDirection") journeyDirection?: string,
@Query("originStationId") originStationId?: string,
@Query("destinationStationId") destinationStationId?: string,
) {
return this.service.getSeatMap(scheduleId, coachTypeId);
return this.service.getSeatMap(
scheduleId,
coachTypeId,
journeyDirection as any,
originStationId,
destinationStationId
);
}
// ── Hold / Release ────────────────────────────────────────────────────────

View File

@@ -1,7 +1,13 @@
import { IsString, IsArray, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export enum JourneyDirection {
ONE_WAY = 'ONE_WAY',
OUTBOUND = 'OUTBOUND',
RETURN = 'RETURN'
}
export class PassengerSeatDto {
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
@IsString() passengerId: string;
@@ -20,6 +26,15 @@ export class HoldSeatsDto {
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@ApiPropertyOptional({
enum: JourneyDirection,
example: JourneyDirection.OUTBOUND,
description: 'Journey direction for round-trip bookings. ONE_WAY for single journeys, OUTBOUND/RETURN for round-trip legs. Allows same seats to be held for different directions.'
})
@IsOptional()
@IsEnum(JourneyDirection)
journeyDirection?: JourneyDirection;
@ApiProperty({
type: [PassengerSeatDto],
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',

View File

@@ -1,6 +1,6 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@@ -13,7 +13,7 @@ export class SeatsService {
private systemConfig: SystemConfigService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string) {
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
@@ -37,7 +37,13 @@ export class SeatsService {
});
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
const effectiveStatuses = await this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
journeyDirection
);
return {
coaches: assignments.map((a) => {
@@ -179,6 +185,7 @@ export class SeatsService {
seatIds: string[],
originStationId?: string,
destinationStationId?: string,
journeyDirection?: JourneyDirection,
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
@@ -211,9 +218,13 @@ export class SeatsService {
select: { seatIds: true, createdBy: true },
});
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
for (const hold of activeHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
let holdDirection = JourneyDirection.ONE_WAY;
try {
if (hold.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(hold.createdBy);
@@ -221,16 +232,26 @@ export class SeatsService {
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
}
} catch { /* ignore */ }
for (const seatId of hold.seatIds) {
if (!seatIds.includes(seatId)) continue;
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
} else {
statusMap.set(seatId, 'HELD');
}
// Check leg overlap
const legsOverlap =
reqFrom === undefined || reqTo === undefined ||
holdFrom === undefined || holdTo === undefined ||
(holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
// Check direction conflict
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
if (!directionsConflict) continue;
statusMap.set(seatId, 'HELD');
}
}
@@ -266,6 +287,36 @@ export class SeatsService {
return statusMap;
}
/**
* Check if two journey directions conflict (should not be allowed simultaneously)
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
*/
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
return false;
}
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
if (current === existing) {
return true;
}
// ONE_WAY conflicts with other ONE_WAY bookings only
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
return true;
}
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
return true;
}
// Default: no conflict
return false;
}
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
@@ -307,19 +358,16 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) =>
stopTimes.find(s => s.stationId === stationId)?.sequence;
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
@@ -328,57 +376,46 @@ export class SeatsService {
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── Check existing holds for overlap ────────────────────────────────────
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
for (const h of activeHolds) {
const rawSeatIds = h.seatIds as string[];
let holdDirection = JourneyDirection.ONE_WAY;
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
let passengerIds: string[] = [];
let legUnknown = true;
try {
if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId);
parsedHolds.push({
seatIds: rawSeatIds,
from: holdFrom ?? 0,
to: holdTo ?? Number.MAX_SAFE_INTEGER,
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
legUnknown: holdFrom === undefined || holdTo === undefined,
});
} else {
// Legacy plain-string createdBy — can't determine leg; block conservatively.
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
holdFrom = seqOf(meta.originStationId) ?? 0;
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
legUnknown = !meta.originStationId || !meta.destinationStationId;
}
} catch {
// Malformed JSON — block conservatively.
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
}
}
} catch { /* ignore */ }
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
if (!legsOverlap) continue;
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg`,
);
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
if (!directionsConflict) continue;
for (const { passengerId, seatId } of dto.passengers) {
if (rawSeatIds.includes(seatId)) {
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
}
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg`,
);
if (!legUnknown && passengerIds.includes(passengerId)) {
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
}
}
}
// ── Check confirmed JourneySegments for overlap ──────────────────────────
const bookedSegments = await tx.journeySegment.findMany({
where: {
scheduleId: dto.scheduleId,
@@ -392,25 +429,19 @@ export class SeatsService {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
const overlaps = (segFrom === undefined || segTo === undefined)
? true
: segFrom < reqTo && reqFrom < segTo;
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
if (overlaps) {
throw new ConflictException(
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
);
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
}
}
const holdMeta = {
originStationId: dto.originStationId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: currentDirection,
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
// Mark seats as HELD so the status check catches them immediately on any
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
await tx.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'HELD' },
@@ -418,10 +449,10 @@ export class SeatsService {
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
scheduleId: dto.scheduleId,
passengerId: dto.passengers[0].passengerId,
seatIds,
createdBy: JSON.stringify(holdMeta),
createdBy: JSON.stringify(holdMeta),
expiresAt,
},
});