feat: Wahl result view + live WebSocket chat in the Flutter client

Backend:
- fix(chat): ChatGateway stored the per-socket caller only after the async
  token check resolved, so a client that sent chat:join immediately on open
  raced ahead and got 4001. The caller is now stored as a promise that the
  message handlers await. Verified with a two-client send/receive E2E test
  against local Postgres.

Client (client/app/):
- Wahl screen gains a "Ergebnis" tab backed by GET /wahl/guest/results
  (PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED).
- Chat channel view loads history over REST, then connects the /chat
  WebSocket (chat_socket.dart): live chat:message stream + a compose bar
  that sends chat:send. Shows the socket status.
- web_socket_channel dependency added.

flutter analyze/test/build web all green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:02:40 +02:00
co-authored by Claude Sonnet 5
parent 0b588fa4b7
commit e55faeaa95
8 changed files with 416 additions and 97 deletions
+21 -12
View File
@@ -15,10 +15,14 @@ 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 WeakMap<WebSocket, ChatCaller>();
private readonly callers = new Map<WebSocket, Promise<ChatCaller>>();
private readonly rooms = new Map<string, Set<WebSocket>>();
constructor(
@@ -26,18 +30,18 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly chat: ChatService,
) {}
async handleConnection(client: WebSocket, request: IncomingMessage) {
handleConnection(client: WebSocket, request: IncomingMessage) {
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
if (!token) {
client.close(4001, 'Missing token');
return;
}
try {
this.callers.set(client, await this.tokenVerification.verifyEither(token));
} catch (err) {
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) {
@@ -52,7 +56,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string },
) {
const caller = this.requireCaller(client);
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 } };
@@ -63,19 +67,24 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string; body: string },
) {
const caller = this.requireCaller(client);
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 requireCaller(client: WebSocket): ChatCaller {
const caller = this.callers.get(client);
if (!caller) {
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');
}
return caller;
}
private roomFor(channelId: string): Set<WebSocket> {