import { Logger } from '@nestjs/common'; import { OnGatewayConnection, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { Passenger as PassengerTypes } from '@edr/types'; import { WsAuthService } from './ws-auth.service'; /** * Server → client push for passenger support chat. Clients only *listen* (no * `@SubscribeMessage`); the handshake is authenticated in `handleConnection`. * Each socket joins a room based on its side: * - backoffice staff → the shared `backoffice` room (see every conversation). * - passengers → their `user:` room (their own tickets only). * * Side is decided by the presence of a `Passenger` row for the IAM user id * (staff have none). A message is emitted to BOTH the owner's room and the * backoffice room so the customer thread, the sender's echo, and every agent's * inbox update live. */ @WebSocketGateway({ namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE, cors: { origin: true, credentials: true }, }) export class SupportGateway implements OnGatewayConnection { private readonly logger = new Logger(SupportGateway.name); private static readonly BACKOFFICE_ROOM = 'backoffice'; @WebSocketServer() private readonly server!: Server; constructor(private readonly wsAuth: WsAuthService) {} async handleConnection(socket: Socket): Promise { const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); // A valid token means a backoffice agent: the portal connects only with a // device/guest id (never a token), so every token-authed socket is staff. // Join the shared backoffice room — no passenger-row heuristic needed. if (userId) { socket.data.userId = userId; socket.data.side = 'AGENT'; await socket.join(SupportGateway.BACKOFFICE_ROOM); socket.emit('support:hello', { side: 'AGENT', room: SupportGateway.BACKOFFICE_ROOM, userId, }); this.logger.debug(`support socket ${socket.id} → AGENT (backoffice)`); return; } // Guest: no valid token, but a client-generated guestId scopes the room. // Anyone holding the guestId can see that thread (no account = weaker // ownership) — an accepted MVP trade-off for guest support. const guestId = this.extractGuestId(socket); if (guestId) { socket.data.guestId = guestId; socket.data.side = 'USER'; await socket.join(`guest:${guestId}`); return; } this.logger.debug(`Rejected passenger-support handshake ${socket.id}`); socket.disconnect(true); } /** Push a new message + updated conversation to the owner + backoffice rooms. */ emitMessage( ownerRoom: string | null, conversation: PassengerTypes.PassengerSupportConversationDto, message: PassengerTypes.PassengerSupportMessageDto, ): void { const payload = { conversation, message }; for (const room of this.targetRooms(ownerRoom)) { const to = this.server.to(room); to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload); to.emit( PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, conversation, ); } } /** Push a conversation metadata change (e.g. status) to both rooms. */ emitConversationUpdated( ownerRoom: string | null, conversation: PassengerTypes.PassengerSupportConversationDto, ): void { for (const room of this.targetRooms(ownerRoom)) { this.server .to(room) .emit( PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, conversation, ); } } private targetRooms(ownerRoom: string | null): string[] { const rooms = [SupportGateway.BACKOFFICE_ROOM]; if (ownerRoom) rooms.push(ownerRoom); return rooms; } private extractGuestId(socket: Socket): string | undefined { const authGuest = socket.handshake.auth?.guestId as string | undefined; if (authGuest) return authGuest; const queryGuest = socket.handshake.query?.guestId; if (typeof queryGuest === 'string') return queryGuest; return undefined; } 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; } }