Files
edr-platform/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts
2026-07-17 10:59:25 +00:00

123 lines
4.0 KiB
TypeScript

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:<companyId>` 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<void> {
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;
}
}