Segment based seat assignement added

This commit is contained in:
Roba Boru
2026-05-27 15:11:23 +03:00
parent 3c6bd724f2
commit 2ceb993c60
10 changed files with 630 additions and 290 deletions

View File

@@ -26,6 +26,34 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
// ── Hold / Release ────────────────────────────────────────────────────────
@Get('holds')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'List active seat holds with full leg context',
description: `Returns all non-expired holds enriched with:
- **schedule**: train number, departure/arrival, full route origin→destination
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
- **seats**: seat label, coach, seat class, row, col
- **ttlSeconds**: seconds remaining before the hold expires
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
})
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' })
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' })
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' })
getHolds(
@Query('scheduleId') scheduleId?: string,
@Query('passengerId') passengerId?: string,
) { return this.service.getHolds(scheduleId, passengerId); }
@Get('holds/:holdId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get a single hold with full leg context' })
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' })
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' })
@ApiResponse({ status: 404, description: 'Hold not found' })
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
@Post('hold')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({

View File

@@ -1,9 +1,35 @@
import { IsString, IsArray, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsArray, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class PassengerSeatDto {
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
@IsString() passengerId: string;
@ApiProperty({ example: 'seat-uuid', description: 'Seat UUID assigned to this passenger' })
@IsString() seatId: string;
}
export class HoldSeatsDto {
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
@ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[];
@ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID — scopes the hold to a leg so the seat can be reused on non-overlapping legs' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@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.',
example: [
{ passengerId: 'passenger-uuid-1', seatId: 'seat-uuid-1' },
{ passengerId: 'passenger-uuid-2', seatId: 'seat-uuid-2' },
],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => PassengerSeatDto)
passengers: PassengerSeatDto[];
}

View File

@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
@Module({
imports: [SegmentsModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],
})
export class SeatsModule {}

View File

@@ -1,11 +1,15 @@
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
@Injectable()
export class SeatsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
async getSeatMap(scheduleId: string, coachId?: string) {
@@ -14,6 +18,10 @@ export class SeatsService {
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
orderBy: { positionNumber: 'asc' },
});
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
return {
coaches: assignments.map((a) => ({
id: a.coach.id,
@@ -21,34 +29,334 @@ export class SeatsService {
name: `Coach ${a.coach.label}`,
seatClass: a.coach.seatClass.name,
positionNumber: a.positionNumber,
seats: a.coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
seats: a.coach.seats.map((s) => ({
id: s.id,
number: s.label,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
})),
})),
};
}
/**
* Computes the effective seat status for a set of seats on a specific schedule
* by checking active SeatHolds and confirmed JourneySegments.
*
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
*
* This is needed because seat.status is no longer written during booking —
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
*/
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
expiresAt: { gt: new Date() },
seatIds: { hasSome: seatIds },
},
select: { seatIds: true },
});
for (const hold of activeHolds) {
for (const seatId of hold.seatIds) {
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
}
}
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
// (overwrites HELD if the same seat has a confirmed booking)
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
});
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
}
return statusMap;
}
// ── Hold / Release ────────────────────────────────────────────────────────
async holdSeats(dto: HoldSeatsDto) {
// ── Validate request integrity ───────────────────────────────────────────
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
if (new Set(passengerIds).size !== passengerIds.length)
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } });
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },
select: { id: true, status: true, label: true },
});
if (seats.length !== seatIds.length) {
const found = new Set(seats.map(s => s.id));
const missing = seatIds.filter(id => !found.has(id));
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
// ── 2. Resolve requested leg sequences ──────────────────────────────
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 reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
if (reqFrom === undefined || reqTo === undefined)
throw new BadRequestException('Origin or destination station not found on this schedule');
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── 3. Load active holds for this schedule ───────────────────────────
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
// Parse each hold's leg range and passenger list
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
for (const h of activeHolds) {
try {
if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId);
if (holdFrom !== undefined && holdTo !== undefined) {
parsedHolds.push({
seatIds: h.seatIds,
from: holdFrom,
to: holdTo,
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
});
}
}
} catch { /* ignore malformed */ }
}
// ── 4. Per-passenger validation with overlap check ───────────────────
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
if (!legsOverlap) continue; // non-overlapping leg — no conflict
// Rule A: seat is held on an overlapping leg
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
);
}
// Rule B: passenger already holds a seat on an overlapping leg
if (hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
);
}
}
}
// Store passenger→seat mapping AND leg in createdBy as JSON
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
passengerId: dto.passengers[0].passengerId,
seatIds,
createdBy: JSON.stringify(holdMeta),
expiresAt,
},
});
});
return { id: hold.id, scheduleId: dto.scheduleId, seatIds: dto.seatIds, expiresAt };
return this.enrichHold(hold);
}
async getHolds(scheduleId?: string, passengerId?: string) {
const holds = await this.prisma.seatHold.findMany({
where: {
expiresAt: { gt: new Date() },
...(scheduleId ? { scheduleId } : {}),
...(passengerId ? { passengerId } : {}),
},
orderBy: { createdAt: 'desc' },
});
return Promise.all(holds.map(h => this.enrichHold(h)));
}
async getHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
return this.enrichHold(hold);
}
/**
* Resolves the opaque fareQuoteId leg encoding into human-readable station
* names and enriches the hold with schedule, seat, and leg details.
*/
private async enrichHold(hold: any) {
// Decode leg and passenger→seat mapping from createdBy JSON
let originStationId: string | null = null;
let destinationStationId: string | null = null;
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
try {
if (hold.createdBy) {
const raw = hold.createdBy;
// Guard: only parse if it looks like a JSON object, not a plain number/string
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
const meta = JSON.parse(raw);
originStationId = meta.originStationId ?? null;
destinationStationId = meta.destinationStationId ?? null;
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
}
}
} catch { /* ignore malformed createdBy */ }
const seatIds = hold.seatIds as string[];
const [schedule, originStation, destinationStation, seats] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: hold.scheduleId },
include: { train: true, originStation: true, destinationStation: true },
}),
originStationId ? this.prisma.station.findUnique({ where: { id: originStationId } }) : null,
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
this.prisma.seat.findMany({
where: { id: { in: seatIds } },
include: { coach: { include: { seatClass: true } } },
}),
]);
let originSequence: number | null = null;
let destinationSequence: number | null = null;
if (originStationId && destinationStationId) {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: hold.scheduleId, stationId: { in: [originStationId, destinationStationId] } },
select: { stationId: true, sequence: true },
});
originSequence = stopTimes.find(s => s.stationId === originStationId)?.sequence ?? null;
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
}
// Build seat map keyed by seatId for quick lookup
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
// Merge passenger→seat mapping with seat details
const passengers = passengerSeatMap.length > 0
? passengerSeatMap.map(({ passengerId, seatId }) => {
const s = seatById[seatId];
return {
passengerId,
seat: s ? {
id: s.id,
label: s.label,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
row: s.row,
col: s.col,
} : { id: seatId },
};
})
// Fallback for holds created before this change
: seatIds.map(seatId => {
const s = seatById[seatId];
return {
passengerId: hold.passengerId,
seat: s ? {
id: s.id,
label: s.label,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
row: s.row,
col: s.col,
} : { id: seatId },
};
});
return {
holdId: hold.id,
expiresAt: hold.expiresAt,
createdAt: hold.createdAt,
ttlSeconds: Math.max(0, Math.floor((hold.expiresAt.getTime() - Date.now()) / 1000)),
schedule: schedule ? {
id: schedule.id,
trainNumber: schedule.train.number,
trainName: schedule.train.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
fullRouteOrigin: schedule.originStation.name,
fullRouteDestination: schedule.destinationStation.name,
} : null,
leg: {
originStationId,
originStationName: originStation?.name ?? null,
originStationCode: originStation?.code ?? null,
originSequence,
destinationStationId,
destinationStationName: destinationStation?.name ?? null,
destinationStationCode: destinationStation?.code ?? null,
destinationSequence,
},
passengers,
};
}
async releaseHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } });
await this.prisma.seatHold.delete({ where: { id: holdId } });
return { released: true };
return { released: true, holdId };
}
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
async confirmSeats(seatIds: string[]) {
// No-op for status — availability is segment-scoped via JourneySegment
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
}
async releaseSeats(seatIds: string[]) {
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
// For segment-based bookings, releasing is handled by JourneySegment deletion
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({