import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE, type BatchBoardChangedEvent, type BookingWindowPhaseEvent, } from '@edr/types'; import { Logger } from '@nestjs/common'; import { OnGatewayConnection, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; /** * Server → client push for booking-window state changes. Same handshake model * as the notifications gateway: clients only listen, the token is verified on * connect. Events are broadcast namespace-wide — window state is route-scoped * public information for signed-in users, and clients filter/invalidate their * own queries. */ @WebSocketGateway({ namespace: BOOKING_WINDOW_WS_NAMESPACE, cors: { origin: true, credentials: true }, }) export class BookingWindowGateway implements OnGatewayConnection { private readonly logger = new Logger(BookingWindowGateway.name); @WebSocketServer() private readonly server!: Server; constructor(private readonly wsAuth: WsAuthService) {} async handleConnection(socket: Socket): Promise { const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); if (!userId) { this.logger.debug(`Rejected booking-window handshake ${socket.id}`); socket.disconnect(true); return; } socket.data.userId = userId; // Log at info so "is anyone actually connected?" is answerable from the // API log when diagnosing missing live updates. this.logger.log(`Booking-window client connected (user ${userId})`); } /** Push a schedule's current window state to every connected client. */ emitPhase(schedule: TrainSchedule): void { const payload: BookingWindowPhaseEvent = { scheduleId: schedule.id, originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, direction: schedule.direction ?? null, phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'], bookingWindowStatus: schedule.bookingWindowStatus ?? null, bookingCycleNo: schedule.bookingCycleNo, windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null, windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, }; this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); } /** * Announce that a schedule's batch-board data changed (payment, allocation, * fill, expiry, …). Broadcast namespace-wide like PHASE — the payload carries * no board data, only the scheduleId; clients holding that board refetch. */ emitBatchChanged(scheduleId: string, reason: string): void { const payload: BatchBoardChangedEvent = { scheduleId, reason, at: new Date().toISOString(), }; this.server.emit(BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, payload); } private extractToken(socket: Socket): string | undefined { const authToken = socket.handshake.auth?.token as string | undefined; if (authToken) return authToken; const queryToken = socket.handshake.query?.token; if (typeof queryToken === 'string') return queryToken; const header = socket.handshake.headers?.authorization; if (header?.startsWith('Bearer ')) return header.slice(7); return undefined; } }