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:
+21
-12
@@ -15,10 +15,14 @@ import { ChatCaller, ChatService } from './chat.service';
|
|||||||
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
|
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
|
||||||
/// tracked manually per connected socket. Auth happens once at handshake via
|
/// tracked manually per connected socket. Auth happens once at handshake via
|
||||||
/// a `?token=` query param since passport guards don't run for WS upgrades.
|
/// 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' })
|
@WebSocketGateway({ path: '/chat' })
|
||||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
private readonly logger = new Logger(ChatGateway.name);
|
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>>();
|
private readonly rooms = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,18 +30,18 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
private readonly chat: ChatService,
|
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');
|
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
client.close(4001, 'Missing token');
|
client.close(4001, 'Missing token');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const pending = this.tokenVerification.verifyEither(token).catch((err) => {
|
||||||
this.callers.set(client, await this.tokenVerification.verifyEither(token));
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
||||||
client.close(4001, 'Unauthorized');
|
client.close(4001, 'Unauthorized');
|
||||||
}
|
throw err;
|
||||||
|
});
|
||||||
|
this.callers.set(client, pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: WebSocket) {
|
handleDisconnect(client: WebSocket) {
|
||||||
@@ -52,7 +56,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: WebSocket,
|
@ConnectedSocket() client: WebSocket,
|
||||||
@MessageBody() data: { channelId: string },
|
@MessageBody() data: { channelId: string },
|
||||||
) {
|
) {
|
||||||
const caller = this.requireCaller(client);
|
const caller = await this.resolveCaller(client);
|
||||||
await this.chat.assertCanRead(data.channelId, caller);
|
await this.chat.assertCanRead(data.channelId, caller);
|
||||||
this.roomFor(data.channelId).add(client);
|
this.roomFor(data.channelId).add(client);
|
||||||
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
||||||
@@ -63,19 +67,24 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: WebSocket,
|
@ConnectedSocket() client: WebSocket,
|
||||||
@MessageBody() data: { channelId: string; body: string },
|
@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);
|
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
|
||||||
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
||||||
return { event: 'chat:sent', data: { id: message.id } };
|
return { event: 'chat:sent', data: { id: message.id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireCaller(client: WebSocket): ChatCaller {
|
private async resolveCaller(client: WebSocket): Promise<ChatCaller> {
|
||||||
const caller = this.callers.get(client);
|
const pending = this.callers.get(client);
|
||||||
if (!caller) {
|
if (!pending) {
|
||||||
|
client.close(4001, 'Unauthorized');
|
||||||
|
throw new Error('Unauthorized WS client');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch {
|
||||||
client.close(4001, 'Unauthorized');
|
client.close(4001, 'Unauthorized');
|
||||||
throw new Error('Unauthorized WS client');
|
throw new Error('Unauthorized WS client');
|
||||||
}
|
}
|
||||||
return caller;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private roomFor(channelId: string): Set<WebSocket> {
|
private roomFor(channelId: string): Set<WebSocket> {
|
||||||
|
|||||||
Reference in New Issue
Block a user