This commit is contained in:
Stephanos A
2026-06-24 14:18:33 +03:00
684 changed files with 45768 additions and 17315 deletions

View File

@@ -1,36 +1,64 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SeatsService } from './seats.service';
import { HoldSeatsDto } from './seats.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Patch,
Query,
UseGuards,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
ApiQuery,
ApiResponse,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { SeatsService } from "./seats.service";
import { HoldSeatsDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { IamGuard } from "../../common/iam-adapter";
@ApiTags('Seats')
@Controller('seats')
@ApiTags("Seats")
@Controller("seats")
export class SeatsController {
constructor(private service: SeatsService) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
@Get('seatmap/:scheduleId')
@ApiOperation({
summary: 'Get seat map with real-time availability by class',
description: `Returns seat map for a schedule with availability by seat class:
- Economy Regular
- Economy Bed
- VIP Bed
Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
@Get("seatmap/:scheduleId")
@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.`,
})
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' })
@ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' })
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
@ApiQuery({
name: "coachTypeId",
required: false,
description:
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
})
@ApiResponse({
status: 200,
description:
"List of coaches of the given type with their seats and availability",
})
getSeatMap(
@Param("scheduleId") scheduleId: string,
@Query("coachTypeId") coachTypeId?: string,
) {
return this.service.getSeatMap(scheduleId, coachTypeId);
}
// ── Hold / Release ────────────────────────────────────────────────────────
@Get('holds')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@Get("holds")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: 'List active seat holds with full leg context',
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)
@@ -39,25 +67,45 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
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' })
@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); }
@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); }
@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')
@ApiOperation({
summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)',
@Post("hold")
@ApiOperation({
summary:
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
description: `Temporarily reserves seats for a passenger to complete booking.
**Features:**
@@ -65,74 +113,107 @@ This makes it clear which segment of the route each seat is held for, enabling s
- Auto-release after expiry
- Prevents double booking
- Required before creating booking
- **Public endpoint** - No authentication required (supports guest booking)`
- **Public endpoint** - No authentication required (supports guest booking)`,
})
@ApiResponse({ status: 201, description: 'Seats held successfully with holdId' })
@ApiResponse({ status: 409, description: 'One or more seats unavailable' })
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
@ApiResponse({
status: 201,
description: "Seats held successfully with holdId",
})
@ApiResponse({ status: 409, description: "One or more seats unavailable" })
holdSeats(@Body() dto: HoldSeatsDto) {
return this.service.holdSeats(dto);
}
@Delete('hold/:holdId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Release a seat hold' })
@ApiParam({ name: 'holdId', description: 'Hold UUID' })
@ApiResponse({ status: 200, description: 'Hold released' })
@ApiResponse({ status: 404, description: 'Hold not found' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
@Delete("hold/:holdId")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Release a seat hold" })
@ApiParam({ name: "holdId", description: "Hold UUID" })
@ApiResponse({ status: 200, description: "Hold released" })
@ApiResponse({ status: 404, description: "Hold not found" })
releaseHold(@Param("holdId") holdId: string) {
return this.service.releaseHold(holdId);
}
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat blocked' })
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
@Post(":seatId/block")
@UseGuards(IamGuard)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({ status: 200, description: "Seat blocked" })
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
return this.service.blockSeat(seatId, body.reason);
}
@Delete(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Unblock a seat' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat unblocked' })
unblockSeat(@Param('seatId') seatId: string) {
@Delete(":seatId/block")
@UseGuards(IamGuard)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({ status: 200, description: "Seat unblocked" })
unblockSeat(@Param("seatId") seatId: string) {
return this.service.unblockSeat(seatId);
}
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(':seatId/remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
@ApiResponse({ status: 404, description: 'Seat not found' })
removeSeat(@Param('seatId') seatId: string) {
@Patch(":seatId/remove")
@UseGuards(IamGuard)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Remove a seat by marking with negative seatNumber",
})
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({
status: 200,
description: "Seat removed (seatNumber negated), shows as empty space",
})
@ApiResponse({ status: 404, description: "Seat not found" })
removeSeat(@Param("seatId") seatId: string) {
return this.service.removeSeat(seatId);
}
@Patch(':seatId/undo-remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
@ApiResponse({ status: 404, description: 'Seat not found' })
@ApiResponse({ status: 400, description: 'Seat is not removed' })
undoRemoveSeat(@Param('seatId') seatId: string) {
@Patch(":seatId/undo-remove")
@UseGuards(IamGuard)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Undo seat removal by restoring original seatNumber",
})
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({
status: 200,
description: "Seat restored (negative seatNumber removed)",
})
@ApiResponse({ status: 404, description: "Seat not found" })
@ApiResponse({ status: 400, description: "Seat is not removed" })
undoRemoveSeat(@Param("seatId") seatId: string) {
return this.service.undoRemoveSeat(seatId);
}
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('scheduleId') scheduleId: string) {
@Get("export/csv/:scheduleId")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Export seats as CSV" })
async exportCSV(@Param("scheduleId") scheduleId: string) {
const csv = await this.service.exportSeatsCSV(scheduleId);
return { csv, filename: `seats-${scheduleId}.csv` };
}
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
@Post("import/preview")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Preview CSV import" })
previewCSV(@Body() body: { csv: string }) {
return this.service.previewSeatsCSV(body.csv);
}
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' })
importCSV(@Body() body: { scheduleId: string; csv: string; commit: boolean }) {
@Post("import/commit")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Commit CSV import" })
importCSV(
@Body() body: { scheduleId: string; csv: string; commit: boolean },
) {
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
}
}

View File

@@ -13,9 +13,18 @@ export class SeatsService {
private systemConfig: SystemConfigService,
) {}
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
async getSeatMap(scheduleId: string, coachTypeId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) },
where: {
scheduleId,
...(coachTypeId ? { coach: { coachTypeId } } : {}),
},
include: {
coach: {
include: {
@@ -28,48 +37,110 @@ export class SeatsService {
});
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
return {
coaches: assignments.map((a) => {
const allSeats = a.coach.seats;
const allSeats = a.coach.seats;
const coachTypeName = a.coach.coachType?.name ?? '';
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
const isBedCoach = this.isBedCoach(coachTypeName);
// Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6)
const bedsPerRoom = isBedCoach
? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length
: 0;
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
return {
id: a.coach.id,
assignmentId: a.id,
coachNumber: a.coach.number,
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
const mappedSeats = allSeats.map((s: any) => ({
id: s.id,
seatNumber: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
// Bed-specific fields
...(isBedCoach ? {
room_id: `${a.coach.id}-R${s.row}`,
category: bedCategory,
position: this.colToPosition(s.col),
bed_type: this.bedPositionToType(s.bedPosition),
bedPosition: s.bedPosition,
} : {
bedPosition: s.bedPosition,
}),
}));
const base = {
id: a.coach.id,
assignmentId: a.id,
coachNumber: a.coach.number,
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
coachTypeName,
isBedCoach,
bedCategory,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity,
seats: allSeats.map((s) => ({
id: s.id,
seatNumber: s.seatNumber,
number: s.seatNumber,
label: s.seatNumber,
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,
coach: {
id: a.coach.id,
coachNumber: a.coach.number,
label: a.coach.number,
},
})),
totalSeats: a.coach.capacity,
};
if (isBedCoach) {
// Group seats into rooms; row = room number
const roomMap = new Map<number, any[]>();
for (const seat of mappedSeats) {
if (!roomMap.has(seat.row)) roomMap.set(seat.row, []);
roomMap.get(seat.row)!.push(seat);
}
const rooms = Array.from(roomMap.entries())
.sort(([a], [b]) => a - b)
.map(([roomNumber, beds]) => ({
room_id: `${a.coach.id}-R${roomNumber}`,
roomNumber,
category: bedCategory,
totalBeds: beds.length,
beds,
}));
return { ...base, rooms, seats: mappedSeats };
}
return { ...base, seats: mappedSeats };
}),
};
}
private isBedCoach(coachTypeName: string): boolean {
const n = coachTypeName.toLowerCase();
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
}
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
const n = coachTypeName.toLowerCase();
// Explicit VIP name check first
if (n.includes('vip')) return 'VIP_BED';
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
if (bedsPerRoom === 4) return 'VIP_BED';
return 'ECONOMY_BED';
}
// col format: L1, L2, L3, R1, R2, R3
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
}
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
if (!bedPosition) return null;
const map: Record<string, 'LOWER' | 'MIDDLE' | 'UPPER'> = {
lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER',
};
return map[bedPosition.toLowerCase()] ?? null;
}
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -446,7 +517,7 @@ export class SeatsService {
// Delete the Journey (and its JourneySegments) scoped to this booking.
async releaseSeats(bookingId: string) {
await this.prisma.journey.deleteMany({ where: { bookingId } });
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {