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 /// 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> {
+56
View File
@@ -145,6 +145,44 @@ class GuestOverview {
); );
} }
enum WahlResultStatus { pending, assigned, unassigned }
class WahlResult {
WahlResult({
required this.wahlName,
required this.datumsSchluessel,
required this.teil,
required this.status,
required this.workshopName,
required this.wunschRang,
required this.isForced,
});
final String wahlName;
final String datumsSchluessel;
final String teil;
final WahlResultStatus status;
final String? workshopName;
final int? wunschRang;
final bool isForced;
factory WahlResult.fromJson(Map<String, dynamic> j) {
final wahl = j['wahl'] as Map<String, dynamic>;
return WahlResult(
wahlName: wahl['name'] as String,
datumsSchluessel: wahl['datumsSchluessel'] as String,
teil: wahl['teil'] as String,
status: switch (j['status'] as String?) {
'ASSIGNED' => WahlResultStatus.assigned,
'UNASSIGNED' => WahlResultStatus.unassigned,
_ => WahlResultStatus.pending,
},
workshopName: j['workshopName'] as String?,
wunschRang: (j['wunschRang'] as num?)?.toInt(),
isForced: j['isForced'] as bool? ?? false,
);
}
}
class FileEntry { class FileEntry {
FileEntry({required this.id, required this.filename, required this.visibility}); FileEntry({required this.id, required this.filename, required this.visibility});
final String id; final String id;
@@ -262,6 +300,11 @@ class Api {
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds}); await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
} }
Future<List<WahlResult>> guestWahlResults() async {
final list = await _get('/wahl/guest/results') as List<dynamic>;
return list.map((e) => WahlResult.fromJson(e as Map<String, dynamic>)).toList();
}
// --- files --- // --- files ---
Future<List<FileEntry>> files(String kcId) async { Future<List<FileEntry>> files(String kcId) async {
final list = await _get('/files/$kcId') as List<dynamic>; final list = await _get('/files/$kcId') as List<dynamic>;
@@ -270,6 +313,19 @@ class Api {
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId'; String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
/// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the
/// `/api` prefix) and authenticates via a `?token=` query param.
Uri chatWsUri() {
final base = Uri.parse(kApiBase);
return Uri(
scheme: base.scheme == 'https' ? 'wss' : 'ws',
host: base.host,
port: base.hasPort ? base.port : null,
path: '/chat',
queryParameters: {'token': token ?? ''},
);
}
// --- chat (read-only for now; sending is a WebSocket-only path) --- // --- chat (read-only for now; sending is a WebSocket-only path) ---
Future<List<ChatChannel>> channels(String kcId) async { Future<List<ChatChannel>> channels(String kcId) async {
final list = await _get('/chat/$kcId/channels') as List<dynamic>; final list = await _get('/chat/$kcId/channels') as List<dynamic>;
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'api.dart';
/// Thin wrapper over the raw `ws` chat gateway. The NestJS `WsAdapter`
/// expects `{"event": ..., "data": ...}` frames in both directions.
class ChatSocket {
ChatSocket(this._uri);
final Uri _uri;
WebSocketChannel? _channel;
final _messages = StreamController<ChatMessage>.broadcast();
final _status = StreamController<String>.broadcast();
/// Incoming `chat:message` frames.
Stream<ChatMessage> get messages => _messages.stream;
/// "connected" / "closed" / "error: ..." for a small status line.
Stream<String> get status => _status.stream;
void connect(String channelId) {
_channel = WebSocketChannel.connect(_uri);
_channel!.stream.listen(
(raw) {
_status.add('connected');
try {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['event'] == 'chat:message') {
_messages.add(ChatMessage.fromJson(frame['data'] as Map<String, dynamic>));
}
} catch (_) {
// ignore frames we don't model
}
},
onError: (Object e) => _status.add('error: $e'),
onDone: () => _status.add('closed'),
);
_send('chat:join', {'channelId': channelId});
}
void sendMessage(String channelId, String body) {
_send('chat:send', {'channelId': channelId, 'body': body});
}
void _send(String event, Map<String, dynamic> data) {
_channel?.sink.add(jsonEncode({'event': event, 'data': data}));
}
void dispose() {
_channel?.sink.close();
_messages.close();
_status.close();
}
}
+111 -34
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../api.dart'; import '../api.dart';
import '../chat_socket.dart';
import '../main.dart'; import '../main.dart';
/// Read-only chat view. Sending a message is a WebSocket-only path on the /// Chat: channel list (REST) + a per-channel view that loads history over
/// backend (`chat:send`); wiring that up is a follow-up. /// REST and then streams live messages over the `/chat` WebSocket gateway
/// (`chat:join` / `chat:send` / `chat:message`).
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.kcId}); const ChatScreen({super.key, required this.kcId});
final String kcId; final String kcId;
@@ -85,41 +87,130 @@ class _ChannelMessages extends StatefulWidget {
} }
class _ChannelMessagesState extends State<_ChannelMessages> { class _ChannelMessagesState extends State<_ChannelMessages> {
Future<List<ChatMessage>>? _future; final List<ChatMessage> _messages = [];
final _composer = TextEditingController();
final _scroll = ScrollController();
ChatSocket? _socket;
bool _loading = true;
String? _loadError;
String _wsStatus = 'verbinde…';
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_future ??= AppScope.of(context).api.messages(widget.channelId); if (_socket != null) return;
final api = AppScope.of(context).api;
_load(api);
_socket = ChatSocket(api.chatWsUri())
..connect(widget.channelId)
..messages.listen(_onIncoming)
..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null);
}
Future<void> _load(Api api) async {
try {
final history = await api.messages(widget.channelId);
if (!mounted) return;
setState(() {
_messages
..clear()
..addAll(history);
_loading = false;
});
_jump();
} catch (e) {
if (mounted) setState(() { _loadError = '$e'; _loading = false; });
}
}
void _onIncoming(ChatMessage m) {
if (!mounted) return;
setState(() => _messages.add(m));
_jump();
}
void _jump() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
void _send() {
final text = _composer.text.trim();
if (text.isEmpty) return;
_socket?.sendMessage(widget.channelId, text);
_composer.clear();
}
@override
void dispose() {
_socket?.dispose();
_composer.dispose();
_scroll.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.title)), appBar: AppBar(
body: FutureBuilder<List<ChatMessage>>( title: Text(widget.title),
future: _future, bottom: PreferredSize(
builder: (context, snap) { preferredSize: const Size.fromHeight(18),
if (snap.connectionState != ConnectionState.done) { child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)),
return const Center(child: CircularProgressIndicator()); ),
} ),
if (snap.hasError) { body: Column(
return Center( children: [
Expanded(child: _body(context)),
SafeArea(
top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(8),
child: Text('${snap.error}', textAlign: TextAlign.center), child: Row(
children: [
Expanded(
child: TextField(
controller: _composer,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'Nachricht…',
border: OutlineInputBorder(),
isDense: true,
),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
),
),
],
), ),
); );
} }
final messages = snap.data!;
if (messages.isEmpty) { Widget _body(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_loadError!, textAlign: TextAlign.center),
),
);
}
if (_messages.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten.')); return const Center(child: Text('Noch keine Nachrichten.'));
} }
return ListView.builder( return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: messages.length, itemCount: _messages.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final m = messages[i]; final m = _messages[i];
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Container( child: Container(
@@ -134,26 +225,12 @@ class _ChannelMessagesState extends State<_ChannelMessages> {
children: [ children: [
Text(m.body), Text(m.body),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall),
m.createdAt,
style: Theme.of(context).textTheme.labelSmall,
),
], ],
), ),
), ),
); );
}, },
); );
},
),
bottomNavigationBar: const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Senden folgt (WebSocket) — aktuell nur Lesen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
),
);
} }
} }
+1 -1
View File
@@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget {
_NavTile( _NavTile(
icon: Icons.forum, icon: Icons.forum,
title: 'Chat', title: 'Chat',
subtitle: 'Kanäle & Nachrichten (lesen)', subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
onTap: () => _open(context, ChatScreen(kcId: kcId)), onTap: () => _open(context, ChatScreen(kcId: kcId)),
), ),
]; ];
+98 -3
View File
@@ -27,9 +27,16 @@ class _WahlScreenState extends State<WahlScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return DefaultTabController(
appBar: AppBar(title: const Text('Workshop-Wahl')), length: 2,
body: FutureBuilder<GuestOverview>( child: Scaffold(
appBar: AppBar(
title: const Text('Workshop-Wahl'),
bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]),
),
body: TabBarView(
children: [
FutureBuilder<GuestOverview>(
future: _future, future: _future,
builder: (context, snap) { builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) { if (snap.connectionState != ConnectionState.done) {
@@ -53,6 +60,94 @@ class _WahlScreenState extends State<WahlScreen> {
); );
}, },
), ),
const _ErgebnisTab(),
],
),
),
);
}
}
class _ErgebnisTab extends StatefulWidget {
const _ErgebnisTab();
@override
State<_ErgebnisTab> createState() => _ErgebnisTabState();
}
class _ErgebnisTabState extends State<_ErgebnisTab> {
Future<List<WahlResult>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.guestWahlResults();
}
void _reload() {
setState(() => _future = AppScope.of(context).api.guestWahlResults());
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<WahlResult>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: '${snap.error}', onRetry: _reload);
}
final results = snap.data!;
if (results.isEmpty) {
return const Center(child: Text('Noch keine Teilnahme an einer Wahl.'));
}
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [for (final r in results) _ResultCard(result: r)],
),
);
},
);
}
}
class _ResultCard extends StatelessWidget {
const _ResultCard({required this.result});
final WahlResult result;
@override
Widget build(BuildContext context) {
final (label, color, detail) = switch (result.status) {
WahlResultStatus.assigned => (
result.workshopName ?? 'Zugeteilt',
Colors.green,
result.isForced
? 'Fest zugeteilt (Leitungsteam)'
: 'Wunsch ${result.wunschRang ?? '?'}',
),
WahlResultStatus.unassigned => (
'Kein Platz frei',
Theme.of(context).colorScheme.error,
'Bitte beim Leitungsteam melden.',
),
WahlResultStatus.pending => (
'Noch nicht zugeteilt',
Theme.of(context).colorScheme.outline,
'Die Zuteilung läuft noch.',
),
};
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Icon(Icons.emoji_events, color: color),
title: Text('${result.wahlName} · ${result.datumsSchluessel} Teil ${result.teil}',
style: Theme.of(context).textTheme.bodySmall),
subtitle: Text(label, style: Theme.of(context).textTheme.titleMedium),
trailing: Text(detail, textAlign: TextAlign.end),
),
); );
} }
} }
+24
View File
@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -349,6 +357,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -11,6 +11,7 @@ dependencies:
sdk: flutter sdk: flutter
http: ^1.2.2 http: ^1.2.2
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
web_socket_channel: ^3.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: