Files
KC-APP-Server/src/chat/chat.gateway.ts
T
linus 288628f20e
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
feat(chat): free-form GRUPPE channels with mutable participants
- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
2026-09-12 13:25:48 +02:00

118 lines
4.0 KiB
TypeScript

import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenVerificationService } from '../auth/token-verification.service';
import { ChatCaller, ChatService } from './chat.service';
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
/// tracked manually per connected socket. Auth happens once at handshake via
/// a `?token=` query param since passport guards don't run for WS upgrades.
///
/// The per-socket caller is stored as a *promise*: the token check is async
/// and a client can send `chat:join` before it resolves, so handlers await
/// the stored promise instead of assuming it's already populated.
@WebSocketGateway({ path: '/chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(ChatGateway.name);
private readonly callers = new Map<WebSocket, Promise<ChatCaller>>();
private readonly rooms = new Map<string, Set<WebSocket>>();
constructor(
private readonly tokenVerification: TokenVerificationService,
private readonly chat: ChatService,
) {}
handleConnection(client: WebSocket, request: IncomingMessage) {
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
if (!token) {
client.close(4001, 'Missing token');
return;
}
const pending = this.tokenVerification.verifyEither(token).catch((err) => {
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
client.close(4001, 'Unauthorized');
throw err;
});
this.callers.set(client, pending);
}
handleDisconnect(client: WebSocket) {
this.callers.delete(client);
for (const members of this.rooms.values()) {
members.delete(client);
}
}
@SubscribeMessage('chat:join')
async onJoin(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string },
) {
const caller = await this.resolveCaller(client);
await this.chat.assertCanRead(data.channelId, caller);
this.roomFor(data.channelId).add(client);
return { event: 'chat:joined', data: { channelId: data.channelId } };
}
@SubscribeMessage('chat:send')
async onSend(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string; body: string },
) {
const caller = await this.resolveCaller(client);
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
this.broadcast(data.channelId, { event: 'chat:message', data: message });
return { event: 'chat:sent', data: { id: message.id } };
}
private async resolveCaller(client: WebSocket): Promise<ChatCaller> {
const pending = this.callers.get(client);
if (!pending) {
client.close(4001, 'Unauthorized');
throw new Error('Unauthorized WS client');
}
try {
return await pending;
} catch {
client.close(4001, 'Unauthorized');
throw new Error('Unauthorized WS client');
}
}
private roomFor(channelId: string): Set<WebSocket> {
let room = this.rooms.get(channelId);
if (!room) {
room = new Set();
this.rooms.set(channelId, room);
}
return room;
}
private broadcast(channelId: string, payload: unknown) {
const room = this.rooms.get(channelId);
if (!room) return;
const json = JSON.stringify(payload);
for (const socket of room) {
if (socket.readyState === socket.OPEN) {
socket.send(json);
}
}
}
/// Called by ChatController after add/removeParticipant so anyone with the
/// channel already open (e.g. the creator's participant-management UI)
/// gets a live update. Newly added participants join the room themselves
/// via `chat:join` once they open the chat.
notifyParticipantsChanged(channelId: string) {
this.broadcast(channelId, { event: 'chat:participants-changed', data: { channelId } });
}
}