From 6ea59e7f5eb1024e737bedd7dabaa82a776544b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Fri, 4 Sep 2026 15:26:29 +0200 Subject: [PATCH] Moderacja uczestnika dziala takze przy wiadomosciach na zywo Wyciszenie i blokada byly zbudowane w polowie: backend mial endpointy `/participants/{id}/mute` i `/ban`, migracja V28 jest, a uczestnik z wyciszeniem faktycznie nie mogl pisac. Brakowalo jednego ogniwa. Zdarzenie na temacie moderacji nioslo widok PUBLICZNY, bez identyfikatora zapisu autora, a panel uzupelnial brakujace pola wartosciami domyslnymi (`authorRegistrationId: null`). Warunek pokazujacy przyciski sprawdza wlasnie to pole, wiec: - wiadomosci wczytane przy wejsciu mialy przyciski, - wiadomosci przychodzace NA ZYWO mialy tylko kosz. Komentarz w kodzie zakladal, ze dane uzupelni "najblizsze odswiezenie listy po akcji mute/ban". Zalozenie bylo bledne: zeby wykonac te akcje, trzeba miec przycisk. Bez odswiezenia nie ma przycisku, bez przycisku nie ma odswiezenia, wiec pomagalo dopiero przeladowanie strony. Zgloszone z produkcji. Temat moderacji niesie teraz PELNY widok moderatora, razem z identyfikatorem zapisu i stanem wyciszenia autora. Jest to bezpieczne, bo temat subskrybuje wylacznie panel prowadzacego, uwierzytelniony tokenem administratora. **Strumien publiczny zostaje bez zmian.** `InteractionView` nadal nie niesie identyfikatora zapisu ani adresu e-mail: czat widza wszyscy obecni, wiec nic, co pozwala wskazac konkretna osobe poza spotkaniem, nie moze stamtad wyjsc. Dlatego doszedl osobny `InteractionService.moderatorView`, a nie pole dopisane do widoku publicznego. Mapowanie jest w transakcji: autor to relacja leniwa, a rozglaszanie dzieje sie juz poza nia. Co-Authored-By: Claude Opus 5 --- .../InteractionAdminController.java | 8 ++--- .../interaction/InteractionBroadcaster.java | 16 +++++++-- .../interaction/InteractionService.java | 33 +++++++++++++++++++ .../InteractionStompController.java | 10 ++++-- .../PublicTaskOfferController.java | 6 +++- .../src/pages/webinars/HostConsolePage.tsx | 29 ++++++++-------- 6 files changed, 78 insertions(+), 24 deletions(-) diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java index 7ea3e54..c3f786d 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java @@ -99,7 +99,7 @@ public class InteractionAdminController { } else { broadcaster.questionRemoved(id, view); } - broadcaster.moderation(id, "REJECTED", view); + broadcaster.moderation(id, "REJECTED", interactions.moderatorView(interactionId)); return view; } @@ -110,7 +110,7 @@ public class InteractionAdminController { InteractionView view = interactions.view(interactionId); broadcaster.questionPublished(id, view); - broadcaster.moderation(id, "APPROVED", view); + broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId)); return view; } @@ -122,7 +122,7 @@ public class InteractionAdminController { InteractionView view = interactions.view(interactionId); broadcaster.questionUpdated(id, view); - broadcaster.moderation(id, "QUESTION_UPDATED", view); + broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId)); return view; } @@ -136,7 +136,7 @@ public class InteractionAdminController { InteractionView view = interactions.view(interactionId); broadcaster.questionUpdated(id, view); - broadcaster.moderation(id, "QUESTION_UPDATED", view); + broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId)); return view; } diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java index cad3bdd..9fd2dcf 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java @@ -102,8 +102,18 @@ public class InteractionBroadcaster { messaging.convertAndSend(tasksTopic(webinarId), new TaskEvent(kind, payload)); } - /** Zdarzenie wylacznie dla panelu prowadzacego, takze dla pytan oczekujacych. */ - public void moderation(Long webinarId, String kind, InteractionView interaction) { + /** + * Zdarzenie wylacznie dla panelu prowadzacego, takze dla pytan oczekujacych. + * + * Niesie **pelny widok moderatora**, z identyfikatorem zapisu autora oraz + * jego stanem wyciszenia i blokady. Temat jest prywatny: subskrybuje go + * wylacznie panel, uwierzytelniony tokenem administratora, wiec dane, ktore + * swiadomie nie wychodza na strumien publiczny, moga isc tutaj. + * + * Bez tego panel dostawal wiadomosci na zywo bez identyfikatora autora + * i nie pokazywal przy nich przyciskow wyciszenia ani blokady. + */ + public void moderation(Long webinarId, String kind, ModeratorInteractionView interaction) { messaging.convertAndSend(moderationTopic(webinarId), new Event(kind, interaction)); } @@ -153,7 +163,7 @@ public class InteractionBroadcaster { } /** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */ - public record Event(String kind, InteractionView payload) { + public record Event(String kind, ModeratorInteractionView payload) { } /** Koperta zdarzenia o zadaniu. */ diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java index 66435a9..f95be46 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java @@ -318,6 +318,39 @@ public class InteractionService { return InteractionView.of(interaction, votes, false); } + /** + * Widok dla panelu prowadzacego, skladany **w transakcji**. + * + * DLACZEGO OSOBNO OD `view` + * ----------------------------------------------------------------------- + * `InteractionView` idzie na strumien PUBLICZNY, ktory widza wszyscy obecni, + * wiec swiadomie nie niesie identyfikatora zapisu ani niczego, co pozwala + * wskazac konkretna osobe poza spotkaniem. Panel prowadzacego potrzebuje + * jednak dokladnie tego identyfikatora, zeby dalo sie kogos wyciszyc albo + * zablokowac. + * + * Temat moderacji jest prywatny: subskrybuje go wylacznie panel, + * uwierzytelniony tokenem administratora. Pelny widok moze tam pojsc. + * + * Bez tej metody panel dostawal wiadomosci na zywo z `authorRegistrationId` + * rownym null i **nie pokazywal przy nich przyciskow moderacji**. Pojawialy + * sie dopiero po odswiezeniu strony, bo warunek w widoku sprawdza wlasnie to + * pole. Komentarz w kodzie zakladal, ze uzupelni je "najblizsze odswiezenie + * listy po akcji mute/ban", ale zeby wykonac te akcje, trzeba miec przycisk: + * bez odswiezenia nie ma przycisku, bez przycisku nie ma odswiezenia. + */ + @Transactional(readOnly = true) + public ModeratorInteractionView moderatorView(Long interactionId) { + Interaction interaction = require(interactionId); + long votes = interaction.getType() == InteractionType.QUESTION + ? voteCounts(List.of(interaction.getId())).getOrDefault(interaction.getId(), 0L) + : 0L; + Registration author = interaction.getRegistration(); + return ModeratorInteractionView.of(interaction, votes, + author != null && author.isChatMuted(), + author != null && author.isRoomBanned()); + } + @Transactional(readOnly = true) public Interaction require(Long interactionId) { return interactionRepository.findById(interactionId) diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java index af87ce2..fa7e46c 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java @@ -62,7 +62,9 @@ public class InteractionStompController { Interaction chat = interactions.postChat(author, payload.content()); InteractionView view = interactions.view(chat.getId()); broadcaster.chatPosted(author.webinarId(), view); - broadcaster.moderation(author.webinarId(), "CHAT_POSTED", view); + // panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc + broadcaster.moderation(author.webinarId(), "CHAT_POSTED", + interactions.moderatorView(chat.getId())); } @MessageMapping("/question") @@ -72,7 +74,8 @@ public class InteractionStompController { InteractionView view = interactions.view(question.getId()); // panel prowadzacego widzi kazde pytanie, takze oczekujace - broadcaster.moderation(author.webinarId(), "QUESTION_POSTED", view); + broadcaster.moderation(author.webinarId(), "QUESTION_POSTED", + interactions.moderatorView(question.getId())); // publicznie tylko wtedy, gdy nie wymaga zatwierdzenia if (question.getStatus() == InteractionStatus.APPROVED) { @@ -96,7 +99,8 @@ public class InteractionStompController { InteractionView view = interactions.view(payload.interactionId()); broadcaster.questionUpdated(author.webinarId(), view); - broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED", view); + broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED", + interactions.moderatorView(payload.interactionId())); } /** diff --git a/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java b/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java index 377700e..d9e7ede 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java @@ -44,6 +44,7 @@ public class PublicTaskOfferController { private final TaskService tasks; private final OfferService offers; private final InteractionBroadcaster broadcaster; + private final InteractionService interactions; private final SalesNotifier salesNotifier; private final RegistrationRepository registrationRepository; @@ -52,6 +53,7 @@ public class PublicTaskOfferController { TaskService tasks, OfferService offers, InteractionBroadcaster broadcaster, + InteractionService interactions, SalesNotifier salesNotifier, RegistrationRepository registrationRepository) { this.banners = banners; @@ -59,6 +61,7 @@ public class PublicTaskOfferController { this.tasks = tasks; this.offers = offers; this.broadcaster = broadcaster; + this.interactions = interactions; this.salesNotifier = salesNotifier; this.registrationRepository = registrationRepository; } @@ -124,7 +127,8 @@ public class PublicTaskOfferController { // panel prowadzacego dostaje sygnal osobnym rodzajem zdarzenia, // zeby dalo sie go policzyc oddzielnie od pytan - broadcaster.moderation(result.webinarId(), "OFFER_REQUESTED", result.view()); + broadcaster.moderation(result.webinarId(), "OFFER_REQUESTED", + interactions.moderatorView(result.interactionId())); salesNotifier.notifySales(result); diff --git a/frontend/src/pages/webinars/HostConsolePage.tsx b/frontend/src/pages/webinars/HostConsolePage.tsx index f30e856..abd31a4 100644 --- a/frontend/src/pages/webinars/HostConsolePage.tsx +++ b/frontend/src/pages/webinars/HostConsolePage.tsx @@ -7,7 +7,7 @@ import type { Webinar } from '../../api/types' import { Icon } from '../../components/Icon' import { useToasts } from '../../components/Toasts' import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui' -import type { InteractionView, ModeratorInteractionView } from '../../lib/useInteractions' +import type { ModeratorInteractionView } from '../../lib/useInteractions' import { SpeakQueue, type SpeakRequestView } from './SpeakQueue' import { panelPath } from '../../lib/routes' @@ -217,7 +217,7 @@ export function HostConsolePage() { void webinar.refetch() stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => { - const event = JSON.parse(message.body) as { kind: string; payload: InteractionView } + const event = JSON.parse(message.body) as { kind: string; payload: ModeratorInteractionView } apply(event) }) @@ -233,17 +233,20 @@ export function HostConsolePage() { onWebSocketClose: () => setConnected(false), }) - function apply(event: { kind: string; payload: InteractionView }) { - // zdarzenie WS niesie publiczny widok bez danych moderacji autora. - // Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski - // moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy - // je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu. - const item: ModeratorInteractionView = { - ...event.payload, - authorRegistrationId: null, - authorChatMuted: false, - authorRoomBanned: false, - } + function apply(event: { kind: string; payload: ModeratorInteractionView }) { + /* + * Zdarzenie z tematu moderacji niesie PELNY widok, razem z + * identyfikatorem zapisu autora i jego stanem wyciszenia. + * + * Wczesniej szedl tu widok publiczny, a brakujace pola uzupelniano + * wartosciami domyslnymi (`authorRegistrationId: null`). Skutek: przy + * wiadomosciach przychodzacych NA ZYWO panel nie pokazywal przyciskow + * wyciszenia ani blokady, bo warunek sprawdza wlasnie to pole. Pojawialy + * sie dopiero po odswiezeniu strony. Zalozenie, ze uzupelni je + * "najblizsze odswiezenie po akcji mute/ban", bylo bledne: zeby wykonac + * te akcje, trzeba miec przycisk. + */ + const item: ModeratorInteractionView = event.payload // zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo // w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym