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 { AutoAssignHoldDto, 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 { PassengerDelete, PassengerWrite } 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') @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage) @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); } @Post("auto-assign-hold") @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only", description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted. No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold. For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict. Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`, }) @ApiResponse({ status: 201, description: "Seats auto-assigned and held" }) @ApiResponse({ status: 409, description: "Not enough seats available in the requested class" }) autoAssignHold(@Body() dto: AutoAssignHoldDto) { const passengerCount = dto.adultCount + (dto.childCount ?? 0); return this.service.autoAssignAndHold( dto.scheduleId, dto.originStationId, dto.destinationStationId, dto.seatClassName, passengerCount, dto.journeyDirection, ); } @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") @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @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") @PassengerDelete(PASSENGER_PERMS.seats.delete) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Preview CSV import" }) previewCSV(@Body() body: { csv: string }) { return this.service.previewSeatsCSV(body.csv); } @Post("import/commit") @PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @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") @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @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); } }