mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
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:<iamUserId>` 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<void> {
|
|
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;
|
|
}
|
|
}
|