import { SUPPORT_CHAT_WS_EVENTS, SUPPORT_CHAT_WS_NAMESPACE, SupportConversationDto, SupportMessageDto, } from "@edr/types"; import { Logger } from "@nestjs/common"; import { OnGatewayConnection, WebSocketGateway, WebSocketServer, } from "@nestjs/websockets"; import { Server, Socket } from "socket.io"; import { BackofficeService } from "../backoffice/backoffice.service"; import { ExternalProfileRepository } from "../companies/external-profile.repository"; import { WsAuthService } from "../notification-inbox/ws-auth.service"; /** * Server → client push for support chat. Clients only *listen* (no * `@SubscribeMessage`); the handshake is authenticated in `handleConnection` * (reusing the notification module's {@link WsAuthService}). Each socket joins a * room based on its side: * - backoffice staff → the shared `backoffice` room (see every conversation). * - portal users → their `company:` room (their tickets only). * * A message is emitted to *both* the company room and the backoffice room so the * customer thread, the sender's echo, and every other agent's inbox update live. */ @WebSocketGateway({ namespace: SUPPORT_CHAT_WS_NAMESPACE, cors: { origin: true, credentials: true }, }) export class SupportChatGateway implements OnGatewayConnection { private readonly logger = new Logger(SupportChatGateway.name); private static readonly BACKOFFICE_ROOM = "backoffice"; @WebSocketServer() private readonly server!: Server; constructor( private readonly wsAuth: WsAuthService, private readonly backoffice: BackofficeService, private readonly externalProfiles: ExternalProfileRepository, ) {} async handleConnection(socket: Socket): Promise { const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); if (!userId) { this.logger.debug(`Rejected support-chat handshake ${socket.id}`); socket.disconnect(true); return; } socket.data.userId = userId; try { const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds(); if (staffIds.includes(userId)) { await socket.join(SupportChatGateway.BACKOFFICE_ROOM); socket.data.side = "AGENT"; return; } } catch (err) { this.logger.warn(`Staff lookup failed: ${(err as Error).message}`); } const profile = await this.externalProfiles.findByUserId(userId); if (profile?.companyId) { await socket.join(this.companyRoom(profile.companyId)); socket.data.side = "CUSTOMER"; socket.data.companyId = profile.companyId; } } /** Push a new message + updated conversation to the company and backoffice rooms. */ emitMessage( companyId: string, conversation: SupportConversationDto, message: SupportMessageDto, ): void { const payload = { conversation, message }; for (const room of this.targetRooms(companyId)) { const to = this.server.to(room); to.emit(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, payload); to.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation); } } /** Push a conversation metadata change (e.g. status) to both rooms. */ emitConversationUpdated( companyId: string, conversation: SupportConversationDto, ): void { for (const room of this.targetRooms(companyId)) { this.server .to(room) .emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation); } } private targetRooms(companyId: string): string[] { return [this.companyRoom(companyId), SupportChatGateway.BACKOFFICE_ROOM]; } private companyRoom(companyId: string): string { return `company:${companyId}`; } 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; } }