import { Body, Controller, Delete, Get, Param, Post, Patch, Query, Req, SetMetadata, UseGuards, } from "@nestjs/common"; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse, ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto"; import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-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)", description: "The authenticated staff member is recorded as the blocker — their IAM id in `blockedBy` and their " + "display name in `blockedByName` — so the Blocked Seat Revenue Loss report can attribute the block " + "without a cross-service lookup.", }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiBody({ type: BlockSeatDto }) @ApiResponse({ status: 200, description: "Seat blocked" }) blockSeat( @Param("seatId") seatId: string, @Body() body: BlockSeatDto, @Req() req: RequestWithActingUser, ) { return this.service.blockSeat(seatId, body, resolveActingUser(req)); } @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" }) @ApiBody({ type: SetMaintenanceDto }) @ApiResponse({ status: 200, description: "Seat set to under maintenance" }) setMaintenance( @Param("seatId") seatId: string, @Body() body: SetMaintenanceDto, @Req() req: RequestWithActingUser, ) { return this.service.setMaintenance(seatId, body.reason, resolveActingUser(req)); } @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); } // ── Duplicate seat management (backoffice) ──────────────────────────────── @Get("duplicates") @UseGuards(JwtGuard) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "List duplicate seat assignments by schedule date", description: "Returns all schedules on the given date that have bookings sharing " + "the same seat, grouped by coach. Each coach entry includes the duplicate " + "groups (with full booking info) and the list of currently available seats " + "that can be used for reassignment.", }) @ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" }) @ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" }) @ApiResponse({ status: 200, description: "Duplicate seat report grouped by schedule → coach", schema: { example: { date: "2026-07-17", totalDuplicates: 1, schedules: [{ scheduleId: "uuid", departureAt: "2026-07-17T06:00:00.000Z", origin: "Addis Ababa", destination: "Dire Dawa", coaches: [{ coachId: "uuid", coachNumber: "C1", coachTypeName: "SBC", duplicates: [{ seatId: "uuid", seatNumber: "12A", leg: 1, bookings: [ { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" }, { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" }, ], }], availableSeats: [ { seatId: "uuid", seatNumber: "14B" }, { seatId: "uuid", seatNumber: "15A" }, ], }], }], }, }, }) getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) { return this.service.getDuplicateSeats(query.date, query.scheduleId); } @Post("duplicates/resolve") @UseGuards(JwtGuard) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Auto-assign duplicate bookings to seats in selected coaches", description: "Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " + "The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " + "for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " + "change immediately, then sends an SMS notification to the passenger. " + "Coaches are searched in the order provided; seats within each coach are assigned by row then column.", }) @ApiBody({ type: ResolveDuplicatesDto }) @ApiResponse({ status: 200, description: "Resolution summary — resolved count, unresolved count, per-booking results", schema: { example: { resolved: 2, unresolved: 0, results: [ { bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" }, { bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" }, ], }, }, }) @ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" }) @ApiResponse({ status: 404, description: "BookingSeat ID not found" }) resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) { return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds); } }