mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Patch,
|
|
Query,
|
|
SetMetadata,
|
|
UseGuards,
|
|
} from "@nestjs/common";
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiBearerAuth,
|
|
ApiParam,
|
|
ApiQuery,
|
|
ApiResponse,
|
|
} from "@nestjs/swagger";
|
|
import { SeatsService } from "./seats.service";
|
|
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
|
import { JwtGuard } from "../../common/jwt.guard";
|
|
import { PassengerStaff } from "../../common/passenger-guards";
|
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
|
|
|
@ApiTags("Seats")
|
|
@Controller("seats")
|
|
export class SeatsController {
|
|
constructor(private service: SeatsService) {}
|
|
|
|
// ── Blocked Seats ─────────────────────────────────────────────────────────
|
|
@Get('blocks')
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@ApiBearerAuth('IAM-auth')
|
|
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
|
|
@ApiResponse({ status: 200, description: 'Blocked seat records' })
|
|
getBlockedSeats() {
|
|
return this.service.getBlockedSeats();
|
|
}
|
|
|
|
// ── Coach Availability ────────────────────────────────────────────────────
|
|
@Get('coaches/:scheduleId')
|
|
@SetMetadata('isPublic', true)
|
|
@ApiOperation({
|
|
summary: 'List coaches with remaining seat counts for a schedule',
|
|
description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.',
|
|
})
|
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
|
@ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' })
|
|
@ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' })
|
|
@ApiResponse({ status: 200, description: 'Coaches with seat availability counts' })
|
|
getCoachesWithAvailability(
|
|
@Param('scheduleId') scheduleId: string,
|
|
@Query('originStationId') originStationId?: string,
|
|
@Query('destinationStationId') destinationStationId?: string,
|
|
) {
|
|
return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId);
|
|
}
|
|
|
|
// ── Seat Map ──────────────────────────────────────────────────────────────
|
|
@Get("seatmap/:scheduleId")
|
|
@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. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
|
|
})
|
|
@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)",
|
|
})
|
|
@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:
|
|
"List of coaches of the given type with their seats and availability",
|
|
})
|
|
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,
|
|
journeyDirection as any,
|
|
originStationId,
|
|
destinationStationId
|
|
);
|
|
}
|
|
|
|
// ── 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")
|
|
@SetMetadata('isPublic', true)
|
|
@ApiOperation({
|
|
summary:
|
|
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
|
|
description: `Temporarily reserves seats for a passenger to complete booking.
|
|
|
|
**Features:**
|
|
- 15-minute hold duration
|
|
- Auto-release after expiry
|
|
- Prevents double booking
|
|
- Required before creating 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);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@Post("release")
|
|
@SetMetadata('isPublic', true)
|
|
@ApiOperation({
|
|
summary: "Release a seat hold by holdId (portal-server use only)",
|
|
description:
|
|
"Frees a previously-created hold's seats immediately instead of waiting for it to " +
|
|
"expire — used when a guest or logged-in user changes their seat selection, so the " +
|
|
"stale hold doesn't linger and block that seat for other travellers.\n\n" +
|
|
"This is a public endpoint (no JWT), like POST /seats/hold, since guest sessions have " +
|
|
"no login to authenticate with. It must ONLY ever be called from the passenger portal's " +
|
|
"own Next.js server (a server-side route handler), never directly from browser code — " +
|
|
"calling it straight from client JS would let anyone script mass hold-cancellation " +
|
|
"against other travellers' in-progress seat selections. The portal's server-side proxy " +
|
|
"is what keeps this endpoint's existence out of the browser's network requests.",
|
|
})
|
|
@ApiResponse({ status: 200, description: "Hold released" })
|
|
@ApiResponse({ status: 404, description: "Hold not found" })
|
|
releaseSeatById(@Body() dto: ReleaseHoldDto) {
|
|
return this.service.releaseHold(dto.holdId);
|
|
}
|
|
|
|
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
|
@Post(":seatId/block")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@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; scheduleId?: string }) {
|
|
return this.service.blockSeat(seatId, body.reason, body.scheduleId);
|
|
}
|
|
|
|
@Delete(":seatId/block")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@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, @Query("scheduleId") scheduleId?: string) {
|
|
return this.service.unblockSeat(seatId, scheduleId);
|
|
}
|
|
|
|
// ── Maintenance ───────────────────────────────────────────────────────────
|
|
@Post(":seatId/maintenance")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@ApiBearerAuth("IAM-auth")
|
|
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
|
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
|
@ApiResponse({ status: 200, description: "Seat set to under maintenance" })
|
|
setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
|
|
return this.service.setMaintenance(seatId, body.reason);
|
|
}
|
|
|
|
@Delete(":seatId/maintenance")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@ApiBearerAuth("IAM-auth")
|
|
@ApiOperation({ summary: "Clear seat maintenance status" })
|
|
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
|
@ApiResponse({ status: 200, description: "Seat cleared from maintenance" })
|
|
clearMaintenance(@Param("seatId") seatId: string) {
|
|
return this.service.clearMaintenance(seatId);
|
|
}
|
|
|
|
// ── Remove Seat ────────────────────────────────────────────────────────────
|
|
@Patch(":seatId/remove")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@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")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
|
@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) {
|
|
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" })
|
|
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 },
|
|
) {
|
|
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
|
}
|
|
}
|