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 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -99,7 +99,7 @@ public class InteractionAdminController {
|
|||||||
} else {
|
} else {
|
||||||
broadcaster.questionRemoved(id, view);
|
broadcaster.questionRemoved(id, view);
|
||||||
}
|
}
|
||||||
broadcaster.moderation(id, "REJECTED", view);
|
broadcaster.moderation(id, "REJECTED", interactions.moderatorView(interactionId));
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ public class InteractionAdminController {
|
|||||||
InteractionView view = interactions.view(interactionId);
|
InteractionView view = interactions.view(interactionId);
|
||||||
|
|
||||||
broadcaster.questionPublished(id, view);
|
broadcaster.questionPublished(id, view);
|
||||||
broadcaster.moderation(id, "APPROVED", view);
|
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ public class InteractionAdminController {
|
|||||||
InteractionView view = interactions.view(interactionId);
|
InteractionView view = interactions.view(interactionId);
|
||||||
|
|
||||||
broadcaster.questionUpdated(id, view);
|
broadcaster.questionUpdated(id, view);
|
||||||
broadcaster.moderation(id, "QUESTION_UPDATED", view);
|
broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId));
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ public class InteractionAdminController {
|
|||||||
InteractionView view = interactions.view(interactionId);
|
InteractionView view = interactions.view(interactionId);
|
||||||
|
|
||||||
broadcaster.questionUpdated(id, view);
|
broadcaster.questionUpdated(id, view);
|
||||||
broadcaster.moderation(id, "QUESTION_UPDATED", view);
|
broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId));
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,8 +102,18 @@ public class InteractionBroadcaster {
|
|||||||
messaging.convertAndSend(tasksTopic(webinarId), new TaskEvent(kind, payload));
|
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));
|
messaging.convertAndSend(moderationTopic(webinarId), new Event(kind, interaction));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +163,7 @@ public class InteractionBroadcaster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */
|
/** 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. */
|
/** Koperta zdarzenia o zadaniu. */
|
||||||
|
|||||||
@@ -318,6 +318,39 @@ public class InteractionService {
|
|||||||
return InteractionView.of(interaction, votes, false);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public Interaction require(Long interactionId) {
|
public Interaction require(Long interactionId) {
|
||||||
return interactionRepository.findById(interactionId)
|
return interactionRepository.findById(interactionId)
|
||||||
|
|||||||
+7
-3
@@ -62,7 +62,9 @@ public class InteractionStompController {
|
|||||||
Interaction chat = interactions.postChat(author, payload.content());
|
Interaction chat = interactions.postChat(author, payload.content());
|
||||||
InteractionView view = interactions.view(chat.getId());
|
InteractionView view = interactions.view(chat.getId());
|
||||||
broadcaster.chatPosted(author.webinarId(), view);
|
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")
|
@MessageMapping("/question")
|
||||||
@@ -72,7 +74,8 @@ public class InteractionStompController {
|
|||||||
InteractionView view = interactions.view(question.getId());
|
InteractionView view = interactions.view(question.getId());
|
||||||
|
|
||||||
// panel prowadzacego widzi kazde pytanie, takze oczekujace
|
// 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
|
// publicznie tylko wtedy, gdy nie wymaga zatwierdzenia
|
||||||
if (question.getStatus() == InteractionStatus.APPROVED) {
|
if (question.getStatus() == InteractionStatus.APPROVED) {
|
||||||
@@ -96,7 +99,8 @@ public class InteractionStompController {
|
|||||||
|
|
||||||
InteractionView view = interactions.view(payload.interactionId());
|
InteractionView view = interactions.view(payload.interactionId());
|
||||||
broadcaster.questionUpdated(author.webinarId(), view);
|
broadcaster.questionUpdated(author.webinarId(), view);
|
||||||
broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED", view);
|
broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED",
|
||||||
|
interactions.moderatorView(payload.interactionId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+5
-1
@@ -44,6 +44,7 @@ public class PublicTaskOfferController {
|
|||||||
private final TaskService tasks;
|
private final TaskService tasks;
|
||||||
private final OfferService offers;
|
private final OfferService offers;
|
||||||
private final InteractionBroadcaster broadcaster;
|
private final InteractionBroadcaster broadcaster;
|
||||||
|
private final InteractionService interactions;
|
||||||
private final SalesNotifier salesNotifier;
|
private final SalesNotifier salesNotifier;
|
||||||
private final RegistrationRepository registrationRepository;
|
private final RegistrationRepository registrationRepository;
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ public class PublicTaskOfferController {
|
|||||||
TaskService tasks,
|
TaskService tasks,
|
||||||
OfferService offers,
|
OfferService offers,
|
||||||
InteractionBroadcaster broadcaster,
|
InteractionBroadcaster broadcaster,
|
||||||
|
InteractionService interactions,
|
||||||
SalesNotifier salesNotifier,
|
SalesNotifier salesNotifier,
|
||||||
RegistrationRepository registrationRepository) {
|
RegistrationRepository registrationRepository) {
|
||||||
this.banners = banners;
|
this.banners = banners;
|
||||||
@@ -59,6 +61,7 @@ public class PublicTaskOfferController {
|
|||||||
this.tasks = tasks;
|
this.tasks = tasks;
|
||||||
this.offers = offers;
|
this.offers = offers;
|
||||||
this.broadcaster = broadcaster;
|
this.broadcaster = broadcaster;
|
||||||
|
this.interactions = interactions;
|
||||||
this.salesNotifier = salesNotifier;
|
this.salesNotifier = salesNotifier;
|
||||||
this.registrationRepository = registrationRepository;
|
this.registrationRepository = registrationRepository;
|
||||||
}
|
}
|
||||||
@@ -124,7 +127,8 @@ public class PublicTaskOfferController {
|
|||||||
|
|
||||||
// panel prowadzacego dostaje sygnal osobnym rodzajem zdarzenia,
|
// panel prowadzacego dostaje sygnal osobnym rodzajem zdarzenia,
|
||||||
// zeby dalo sie go policzyc oddzielnie od pytan
|
// 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);
|
salesNotifier.notifySales(result);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Webinar } from '../../api/types'
|
|||||||
import { Icon } from '../../components/Icon'
|
import { Icon } from '../../components/Icon'
|
||||||
import { useToasts } from '../../components/Toasts'
|
import { useToasts } from '../../components/Toasts'
|
||||||
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
|
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 { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
||||||
import { panelPath } from '../../lib/routes'
|
import { panelPath } from '../../lib/routes'
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ export function HostConsolePage() {
|
|||||||
void webinar.refetch()
|
void webinar.refetch()
|
||||||
|
|
||||||
stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => {
|
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)
|
apply(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,17 +233,20 @@ export function HostConsolePage() {
|
|||||||
onWebSocketClose: () => setConnected(false),
|
onWebSocketClose: () => setConnected(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
function apply(event: { kind: string; payload: InteractionView }) {
|
function apply(event: { kind: string; payload: ModeratorInteractionView }) {
|
||||||
// zdarzenie WS niesie publiczny widok bez danych moderacji autora.
|
/*
|
||||||
// Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski
|
* Zdarzenie z tematu moderacji niesie PELNY widok, razem z
|
||||||
// moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy
|
* identyfikatorem zapisu autora i jego stanem wyciszenia.
|
||||||
// je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu.
|
*
|
||||||
const item: ModeratorInteractionView = {
|
* Wczesniej szedl tu widok publiczny, a brakujace pola uzupelniano
|
||||||
...event.payload,
|
* wartosciami domyslnymi (`authorRegistrationId: null`). Skutek: przy
|
||||||
authorRegistrationId: null,
|
* wiadomosciach przychodzacych NA ZYWO panel nie pokazywal przyciskow
|
||||||
authorChatMuted: false,
|
* wyciszenia ani blokady, bo warunek sprawdza wlasnie to pole. Pojawialy
|
||||||
authorRoomBanned: false,
|
* 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
|
// zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo
|
||||||
// w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym
|
// w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym
|
||||||
|
|||||||
Reference in New Issue
Block a user