Files
edr-platform/apps/edr-passenger-api/src/modules/support/support.gateway.ts
2026-07-08 07:25:34 +00:00

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;
}
}