From e55faeaa958621c6e4429c819e20a5811598e943 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:02:40 +0200 Subject: [PATCH] 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 --- backend/src/chat/chat.gateway.ts | 33 ++-- client/app/lib/api.dart | 56 +++++++ client/app/lib/chat_socket.dart | 57 +++++++ client/app/lib/screens/chat_screen.dart | 197 ++++++++++++++++-------- client/app/lib/screens/home_screen.dart | 2 +- client/app/lib/screens/wahl_screen.dart | 143 ++++++++++++++--- client/app/pubspec.lock | 24 +++ client/app/pubspec.yaml | 1 + 8 files changed, 416 insertions(+), 97 deletions(-) create mode 100644 client/app/lib/chat_socket.dart diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts index 27978a9..932d0a1 100644 --- a/backend/src/chat/chat.gateway.ts +++ b/backend/src/chat/chat.gateway.ts @@ -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(); + private readonly callers = new Map>(); private readonly rooms = new Map>(); 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 { + 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 { diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 8d70094..6db9483 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -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 j) { + final wahl = j['wahl'] as Map; + 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 { FileEntry({required this.id, required this.filename, required this.visibility}); final String id; @@ -262,6 +300,11 @@ class Api { await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds}); } + Future> guestWahlResults() async { + final list = await _get('/wahl/guest/results') as List; + return list.map((e) => WahlResult.fromJson(e as Map)).toList(); + } + // --- files --- Future> files(String kcId) async { final list = await _get('/files/$kcId') as List; @@ -270,6 +313,19 @@ class Api { 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) --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; diff --git a/client/app/lib/chat_socket.dart b/client/app/lib/chat_socket.dart new file mode 100644 index 0000000..d15e4eb --- /dev/null +++ b/client/app/lib/chat_socket.dart @@ -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.broadcast(); + final _status = StreamController.broadcast(); + + /// Incoming `chat:message` frames. + Stream get messages => _messages.stream; + + /// "connected" / "closed" / "error: ..." for a small status line. + Stream 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; + if (frame['event'] == 'chat:message') { + _messages.add(ChatMessage.fromJson(frame['data'] as Map)); + } + } 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 data) { + _channel?.sink.add(jsonEncode({'event': event, 'data': data})); + } + + void dispose() { + _channel?.sink.close(); + _messages.close(); + _status.close(); + } +} diff --git a/client/app/lib/screens/chat_screen.dart b/client/app/lib/screens/chat_screen.dart index 63cc8e9..33c795e 100644 --- a/client/app/lib/screens/chat_screen.dart +++ b/client/app/lib/screens/chat_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import '../api.dart'; +import '../chat_socket.dart'; import '../main.dart'; -/// Read-only chat view. Sending a message is a WebSocket-only path on the -/// backend (`chat:send`); wiring that up is a follow-up. +/// Chat: channel list (REST) + a per-channel view that loads history over +/// REST and then streams live messages over the `/chat` WebSocket gateway +/// (`chat:join` / `chat:send` / `chat:message`). class ChatScreen extends StatefulWidget { const ChatScreen({super.key, required this.kcId}); final String kcId; @@ -85,75 +87,150 @@ class _ChannelMessages extends StatefulWidget { } class _ChannelMessagesState extends State<_ChannelMessages> { - Future>? _future; + final List _messages = []; + final _composer = TextEditingController(); + final _scroll = ScrollController(); + ChatSocket? _socket; + bool _loading = true; + String? _loadError; + String _wsStatus = 'verbinde…'; @override void 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 _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 Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(widget.title)), - body: FutureBuilder>( - future: _future, - builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text('${snap.error}', textAlign: TextAlign.center), - ), - ); - } - final messages = snap.data!; - if (messages.isEmpty) { - return const Center(child: Text('Noch keine Nachrichten.')); - } - return ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: messages.length, - itemBuilder: (context, i) { - final m = messages[i]; - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(m.body), - const SizedBox(height: 2), - Text( - 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), + appBar: AppBar( + title: Text(widget.title), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(18), + child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)), ), ), + body: Column( + children: [ + Expanded(child: _body(context)), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(8), + 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), + ], + ), + ), + ), + ], + ), + ); + } + + 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 ListView.builder( + controller: _scroll, + padding: const EdgeInsets.all(12), + itemCount: _messages.length, + itemBuilder: (context, i) { + final m = _messages[i]; + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(m.body), + const SizedBox(height: 2), + Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall), + ], + ), + ), + ); + }, ); } } diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index 44d7586..acd9a8c 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget { _NavTile( icon: Icons.forum, title: 'Chat', - subtitle: 'Kanäle & Nachrichten (lesen)', + subtitle: 'Kanäle, Verlauf & Live-Nachrichten', onTap: () => _open(context, ChatScreen(kcId: kcId)), ), ]; diff --git a/client/app/lib/screens/wahl_screen.dart b/client/app/lib/screens/wahl_screen.dart index ce6eca9..ec4ac52 100644 --- a/client/app/lib/screens/wahl_screen.dart +++ b/client/app/lib/screens/wahl_screen.dart @@ -27,31 +27,126 @@ class _WahlScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Workshop-Wahl')), - body: FutureBuilder( - 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 data = snap.data!; - if (data.wahlen.isEmpty) { - return const Center(child: Text('Aktuell ist keine Wahl geöffnet.')); - } - return ListView( + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Workshop-Wahl'), + bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]), + ), + body: TabBarView( + children: [ + FutureBuilder( + 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 data = snap.data!; + if (data.wahlen.isEmpty) { + return const Center(child: Text('Aktuell ist keine Wahl geöffnet.')); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(data.kcName, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final w in data.wahlen) + _WahlCard(wahl: w, onSubmitted: _reload), + ], + ); + }, + ), + const _ErgebnisTab(), + ], + ), + ), + ); + } +} + +class _ErgebnisTab extends StatefulWidget { + const _ErgebnisTab(); + @override + State<_ErgebnisTab> createState() => _ErgebnisTabState(); +} + +class _ErgebnisTabState extends State<_ErgebnisTab> { + Future>? _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>( + 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: [ - Text(data.kcName, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - for (final w in data.wahlen) - _WahlCard(wahl: w, onSubmitted: _reload), - ], - ); - }, + 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), ), ); } diff --git a/client/app/pubspec.lock b/client/app/pubspec.lock index 0da6913..3fe5047 100644 --- a/client/app/pubspec.lock +++ b/client/app/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" fake_async: dependency: transitive description: @@ -349,6 +357,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml index 47a4147..01e5ac6 100644 --- a/client/app/pubspec.yaml +++ b/client/app/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: sdk: flutter http: ^1.2.2 shared_preferences: ^2.3.2 + web_socket_channel: ^3.0.1 dev_dependencies: flutter_test: