From e9bb4af59f60794eb954a2ba048819c4bfe9897b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Mon, 28 Sep 2026 08:39:45 +0200 Subject: [PATCH] Panel prowadzenia: lista osob na sali i usuwanie po stronie serwera Karta "Na sali" pokazuje liczbe i liste obecnych (przy LiveKit prosto z serwera pokoju, przy innych dostawcach z sygnalow obecnosci), z wyszukiwaniem, paginacja, wyciszeniem na czacie i usuwaniem z sali. Usuniecie uczestnika z zapisu blokuje go i wyrzuca przez removeParticipant; dotad sale opuszczala tylko posluszna przegladarka. Blokada z czatu robi to samo i pyta o potwierdzenie. Zablokowany nie pisze juz na czacie. Prowadzacego nie da sie usunac. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 22 + .../InteractionAdminController.java | 14 +- .../interaction/InteractionService.java | 12 +- .../AttendanceSessionRepository.java | 18 + .../repository/RegistrationRepository.java | 3 + .../webinar/video/LiveKitEgressService.java | 13 +- .../webinar/video/RoomPresenceController.java | 98 +++++ .../webinar/video/RoomPresenceService.java | 382 ++++++++++++++++++ .../src/pages/webinars/HostConsolePage.tsx | 27 +- .../src/pages/webinars/RoomParticipants.tsx | 350 ++++++++++++++++ 10 files changed, 930 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java create mode 100644 frontend/src/pages/webinars/RoomParticipants.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 7321955..01c101d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1671,6 +1671,28 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila. - Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. +### Obecni na sali w panelu prowadzenia + +Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`. + +- **Przy LiveKit lista pochodzi z serwera pokoju** (`RoomServiceClient.listParticipants`), + a nie z naszej bazy. Tylko serwer wie, kto faktycznie odbiera obraz, i tylko tam + widac polaczenia spoza zapisu (klient z kluczem API, pozostalosc po tescie). + Przy pozostalych dostawcach zrodlem sa sygnaly obecnosci; pole `source` mowi, + ktore odpowiedzialo. Awaria serwera pokoju przelacza na sygnaly, nie wywraca karty. +- **Ukryci uczestnicy (Egress) nie sa osobami** i nie trafiaja na liste. +- **Usuniecie uczestnika z zapisu to BLOKADA plus wyrzucenie po stronie serwera.** + Samo wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Zdarzenie BANNED + wyprowadza wylacznie posluszna przegladarke: bot albo wlasny klient zostawaly + na sali, dlatego `removeParticipant` wola takze blokada z czatu. +- **Prowadzacego (`spk-`) nie da sie usunac.** Przy liscie dwustu osob omylkowe + klikniecie jest realne, a webinar zostalby bez obrazu. +- **Zablokowany nie pisze na czacie.** Interceptor STOMP sprawdza blokade tylko + przy CONNECT, wiec `postChat` sprawdza ja przy kazdej wiadomosci. +- Lista jest **odpytywana co 10 sekund**, bo wejscie i wyjscie widzi serwer wideo, + a nie nasz backend: nie ma zdarzenia do rozeslania. Paginacja i wyszukiwanie + po stronie backendu; liczniki dotycza calej sali, nie wyniku wyszukiwania. + ### Tryb otwartego mikrofonu Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia. 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 c3f786d..283594a 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java @@ -4,6 +4,7 @@ import com.softspm.webinar.auth.AuthenticatedUser; import com.softspm.webinar.domain.Interaction; import com.softspm.webinar.domain.InteractionStatus; import com.softspm.webinar.domain.InteractionType; +import com.softspm.webinar.video.RoomPresenceService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -34,11 +35,14 @@ public class InteractionAdminController { private final InteractionService interactions; private final InteractionBroadcaster broadcaster; + private final RoomPresenceService presence; public InteractionAdminController(InteractionService interactions, - InteractionBroadcaster broadcaster) { + InteractionBroadcaster broadcaster, + RoomPresenceService presence) { this.interactions = interactions; this.broadcaster = broadcaster; + this.presence = presence; } /** @@ -161,7 +165,8 @@ public class InteractionAdminController { * Zablokowanie albo odblokowanie wejscia uczestnika na sale. * * Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany - * opuscil sale od razu, a nie dopiero przy odswiezeniu. + * opuscil sale od razu, a nie dopiero przy odswiezeniu, i usuwamy go z pokoju + * po stronie serwera wideo. */ @PostMapping("/participants/{registrationId}/ban") @Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale") @@ -170,6 +175,11 @@ public class InteractionAdminController { @RequestBody ModerationToggle request) { var registration = interactions.setRoomBanned(registrationId, request.value()); broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId); + if (request.value()) { + // zdarzenie BANNED wyprowadza posluszna przegladarke; serwer pokoju + // wyrzuca takze klienta, ktory na zdarzenie nie zareaguje + presence.removeFromRoom(id, "reg-" + registrationId); + } return new ModerationStateView(registration.getId(), registration.isChatMuted(), registration.isRoomBanned()); } 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 f95be46..44db458 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java @@ -87,10 +87,14 @@ public class InteractionService { // Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic // JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany. if (!author.speaker() && author.registrationId() != null) { - boolean muted = registrationRepository.findById(author.registrationId()) - .map(com.softspm.webinar.domain.Registration::isChatMuted) - .orElse(false); - if (muted) { + var registration = registrationRepository.findById(author.registrationId()).orElse(null); + // zablokowany traci takze czat: polaczenie WebSocket nawiazane + // przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT + if (registration != null && registration.isRoomBanned()) { + throw new ValidationException( + "Prowadzący zablokował Ci dostęp do tego spotkania."); + } + if (registration != null && registration.isChatMuted()) { throw new ValidationException( "Prowadzący wyłączył Ci możliwość pisania na czacie."); } diff --git a/backend/src/main/java/com/softspm/webinar/repository/AttendanceSessionRepository.java b/backend/src/main/java/com/softspm/webinar/repository/AttendanceSessionRepository.java index ed9cdd9..95c37bf 100644 --- a/backend/src/main/java/com/softspm/webinar/repository/AttendanceSessionRepository.java +++ b/backend/src/main/java/com/softspm/webinar/repository/AttendanceSessionRepository.java @@ -50,6 +50,24 @@ public interface AttendanceSessionRepository extends JpaRepository findStale(@Param("threshold") OffsetDateTime threshold); + /** + * Kto jest teraz na sali wedlug sygnalow obecnosci. + * + * Zrodlo listy obecnych przy dostawcach, u ktorych nie da sie odpytac + * pokoju (NONE, Jitsi, Zoom). Autor pobierany od razu, bo lista trafia do + * widoku w tej samej transakcji. + */ + @Query(""" + SELECT a FROM AttendanceSession a + LEFT JOIN FETCH a.registration + LEFT JOIN FETCH a.speaker + WHERE a.webinar.id = :webinarId + AND a.leftAt IS NULL + AND a.lastSeenAt >= :threshold + """) + List findPresent(@Param("webinarId") Long webinarId, + @Param("threshold") OffsetDateTime threshold); + /** * Laczny czas obecnosci jednego uczestnika, w sekundach. * diff --git a/backend/src/main/java/com/softspm/webinar/repository/RegistrationRepository.java b/backend/src/main/java/com/softspm/webinar/repository/RegistrationRepository.java index 5e7eb13..0308c0d 100644 --- a/backend/src/main/java/com/softspm/webinar/repository/RegistrationRepository.java +++ b/backend/src/main/java/com/softspm/webinar/repository/RegistrationRepository.java @@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository findByWebinarIdOrderByCreatedAtDesc(Long webinarId); + /** Zablokowani przez prowadzacego, do odblokowania z panelu prowadzenia. */ + List findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(Long webinarId); + List findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status); /* diff --git a/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java b/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java index bd4deb1..7102024 100644 --- a/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java +++ b/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java @@ -424,7 +424,7 @@ public class LiveKitEgressService { private EgressServiceClient egressClient() { return EgressServiceClient.createClient( - httpUrl(apiUrl()), + apiHttpUrl(), settings.get(SettingKey.LIVEKIT_API_KEY), settings.get(SettingKey.LIVEKIT_API_SECRET)); } @@ -453,6 +453,17 @@ public class LiveKitEgressService { * w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego * `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione. */ + /** + * Adres HTTP API serwera widziany przez backend. + * + * Wspolny dla nagrywania i dla obslugi pokoju (`RoomPresenceService`), + * zeby oba odpytywaly ten sam serwer: rozjazd oznaczalby liste obecnych + * z innego pokoju niz ten, ktory nagrywamy. + */ + String apiHttpUrl() { + return httpUrl(apiUrl()); + } + private String apiUrl() { String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL); if (internal != null && !internal.isBlank()) { diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java new file mode 100644 index 0000000..d854e1b --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java @@ -0,0 +1,98 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.interaction.InteractionBroadcaster; +import com.softspm.webinar.interaction.InteractionService; +import com.softspm.webinar.web.NotFoundException; +import com.softspm.webinar.web.ValidationException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Obecni na sali w panelu prowadzenia. + * + * Sciezka pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy spotkanie + * musi moc usunac kogos z sali bez szukania administratora. + */ +@RestController +@RequestMapping("/admin/webinars/{id}/room/participants") +@Tag(name = "Obecni na sali", description = "Lista osob na sali i usuwanie z niej") +public class RoomPresenceController { + + private final RoomPresenceService presence; + private final InteractionService interactions; + private final InteractionBroadcaster broadcaster; + + public RoomPresenceController(RoomPresenceService presence, + InteractionService interactions, + InteractionBroadcaster broadcaster) { + this.presence = presence; + this.interactions = interactions; + this.broadcaster = broadcaster; + } + + @GetMapping + @Operation(summary = "Lista osob na sali, stronicowana, z wyszukiwaniem") + public RoomPresenceService.PresenceView list(@PathVariable Long id, + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size) { + return presence.list(id, q, page, Math.min(size, 100)); + } + + /** + * Usuniecie z sali. + * + * Uczestnik z zapisu dostaje BLOKADE, a dopiero potem jest wyrzucany: samo + * wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Blokade zdejmuje + * sie przyciskiem "Odblokuj" (ten sam endpoint co przy czacie). + * + * Polaczenie spoza zapisu (np. klient z kluczem API) jest tylko wyrzucane: + * nie mamy czego zablokowac, bo nie przeszlo przez nasz bilet. + * + * Prowadzacego usunac nie mozna: webinar zostalby bez obrazu, a przycisk + * klikniety omylkowo przy liscie dwustu osob jest realnym ryzykiem. + */ + @PostMapping("/remove") + @Operation(summary = "Usuwa osobe z sali; uczestnika z zapisu takze blokuje") + public RemoveResult remove(@PathVariable Long id, @Valid @RequestBody RemoveRequest request) { + String identity = request.identity().trim(); + + if (identity.startsWith("spk-")) { + throw new ValidationException("Prowadzącego nie można usunąć z sali."); + } + + Long registrationId = RoomPresenceService.idOf(identity, "reg-"); + boolean banned = false; + if (registrationId != null) { + // identyfikator przychodzi z zewnatrz: zapis musi nalezec do TEGO webinaru + if (!presence.belongsTo(id, registrationId)) { + throw new NotFoundException("Nie znaleziono uczestnika tego webinaru"); + } + interactions.setRoomBanned(registrationId, true); + broadcaster.participantModeration(id, "BANNED", registrationId); + banned = true; + } + + boolean removed = presence.removeFromRoom(id, identity); + return new RemoveResult(identity, banned, removed); + } + + public record RemoveRequest(@NotBlank String identity) { + } + + /** + * @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy + * dostawcy bez tej operacji albo gdy osoba juz wyszla + */ + public record RemoveResult(String identity, boolean banned, boolean removedFromRoom) { + } +} diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java new file mode 100644 index 0000000..c059469 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java @@ -0,0 +1,382 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.domain.AttendanceSession; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarSpeaker; +import com.softspm.webinar.repository.AttendanceSessionRepository; +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.WebinarRepository; +import com.softspm.webinar.repository.WebinarSpeakerRepository; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import com.softspm.webinar.web.NotFoundException; +import io.livekit.server.RoomServiceClient; +import livekit.LivekitModels; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Kto jest teraz na sali i usuwanie z niej. + * + * DWA ZRODLA, JEDNA ODPOWIEDZ + * --------------------------------------------------------------------------- + * Przy LiveKit pytamy **serwer pokoju**: to jedyne miejsce, ktore wie, kto + * faktycznie odbiera obraz. Widac tam takze polaczenia spoza zapisu (klient + * z kluczem API, pozostalosc po tescie), ktorych nasza baza nie zna wcale. + * Przy pozostalych dostawcach pokoju nie da sie odpytac, wiec lista pochodzi + * z sygnalow obecnosci wysylanych przez strone spotkania co minute. + * Pole `source` mowi panelowi, ktore zrodlo odpowiedzialo. + * + * USUNIECIE EGZEKWUJE SERWER, NIE PRZEGLADARKA + * --------------------------------------------------------------------------- + * Blokada ustawiona w bazie odcina nowe bilety, a zdarzenie BANNED kaze + * przegladarce wyjsc. Posluszna przegladarka wychodzi, ale klient wlasny albo + * zmodyfikowana strona zostalyby na sali z waznym biletem. Dlatego przy LiveKit + * usuniecie idzie takze przez `removeParticipant` po stronie serwera pokoju. + * Samo wyrzucenie bez blokady nie ma sensu: uczestnik wrocilby tym samym + * linkiem po kilku sekundach. + */ +@Service +public class RoomPresenceService { + + private static final Logger log = LoggerFactory.getLogger(RoomPresenceService.class); + + public static final String SOURCE_LIVEKIT = "LIVEKIT"; + public static final String SOURCE_ATTENDANCE = "ATTENDANCE"; + + public static final String KIND_SPEAKER = "SPEAKER"; + public static final String KIND_PARTICIPANT = "PARTICIPANT"; + public static final String KIND_OTHER = "OTHER"; + + private final WebinarRepository webinarRepository; + private final RegistrationRepository registrationRepository; + private final WebinarSpeakerRepository speakerRepository; + private final AttendanceSessionRepository attendanceRepository; + private final VideoProviderFactory providerFactory; + private final LiveKitVideoProvider liveKit; + private final LiveKitEgressService egressService; + private final SettingsService settings; + + public RoomPresenceService(WebinarRepository webinarRepository, + RegistrationRepository registrationRepository, + WebinarSpeakerRepository speakerRepository, + AttendanceSessionRepository attendanceRepository, + VideoProviderFactory providerFactory, + LiveKitVideoProvider liveKit, + LiveKitEgressService egressService, + SettingsService settings) { + this.webinarRepository = webinarRepository; + this.registrationRepository = registrationRepository; + this.speakerRepository = speakerRepository; + this.attendanceRepository = attendanceRepository; + this.providerFactory = providerFactory; + this.liveKit = liveKit; + this.egressService = egressService; + this.settings = settings; + } + + /** + * Lista obecnych, stronicowana i filtrowana po stronie serwera. + * + * Liczniki (`speakers`, `participants`, `others`) dotycza CALEJ sali, nie + * biezacej strony ani wyniku wyszukiwania: prowadzacy ma widziec, ilu + * ludzi slucha, niezaleznie od tego, czego akurat szuka. + */ + @Transactional(readOnly = true) + public PresenceView list(Long webinarId, String query, int page, int size) { + Webinar webinar = webinarRepository.findById(webinarId) + .orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru")); + + List all; + String source; + String problem = null; + + if (usesLiveKit(webinar)) { + source = SOURCE_LIVEKIT; + List fromRoom; + try { + fromRoom = fromLiveKit(webinar); + } catch (Exception e) { + // brak polaczenia z serwerem pokoju nie moze zaslonic reszty + // panelu prowadzenia; pokazujemy liste z sygnalow obecnosci + log.warn("Obecni: odpytanie LiveKit nieudane dla webinaru {}: {}", webinarId, e.getMessage()); + problem = "Nie udało się odpytać serwera wideo. Lista pochodzi z sygnałów obecności " + + "i może nie obejmować osób, które weszły przed chwilą."; + source = SOURCE_ATTENDANCE; + fromRoom = fromAttendance(webinar); + } + all = fromRoom; + } else { + source = SOURCE_ATTENDANCE; + all = fromAttendance(webinar); + } + + // prowadzacy na gorze, potem mowiacy, potem alfabetycznie + all = all.stream() + .sorted(Comparator + .comparing((ParticipantView p) -> !KIND_SPEAKER.equals(p.kind())) + .thenComparing(p -> !(p.audio() || p.video() || p.screen())) + .thenComparing(p -> p.name() == null ? "" : p.name().toLowerCase(Locale.ROOT))) + .toList(); + + int speakers = (int) all.stream().filter(p -> KIND_SPEAKER.equals(p.kind())).count(); + int participants = (int) all.stream().filter(p -> KIND_PARTICIPANT.equals(p.kind())).count(); + int others = all.size() - speakers - participants; + + List filtered = filter(all, query); + int safeSize = Math.max(1, Math.min(size, 100)); + int totalPages = Math.max(1, (filtered.size() + safeSize - 1) / safeSize); + int safePage = Math.max(0, Math.min(page, totalPages - 1)); + int from = safePage * safeSize; + List items = filtered.subList( + Math.min(from, filtered.size()), Math.min(from + safeSize, filtered.size())); + + List banned = registrationRepository + .findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(webinarId).stream() + .map(r -> new BannedView(r.getId(), fullName(r), r.getEmail())) + .toList(); + + return new PresenceView(source, all.size(), speakers, participants, others, + List.copyOf(items), safePage, safeSize, totalPages, filtered.size(), + banned, problem, OffsetDateTime.now()); + } + + /** Czy zapis nalezy do tego webinaru. Identyfikator przychodzi z zewnatrz. */ + @Transactional(readOnly = true) + public boolean belongsTo(Long webinarId, Long registrationId) { + return registrationRepository.findById(registrationId) + .map(r -> r.getWebinar().getId().equals(webinarId)) + .orElse(false); + } + + /** + * Usuwa polaczenie z pokoju u dostawcy. + * + * Nietransakcyjne: wola serwer po sieci, a trzymanie w tym czasie + * polaczenia z baza niczemu nie sluzy. Przy dostawcach bez takiej operacji + * zwraca false; wtedy uczestnika z sali wyprowadza jego przegladarka po + * zdarzeniu BANNED. + * + * @return czy serwer pokoju potwierdzil usuniecie + */ + public boolean removeFromRoom(Long webinarId, String identity) { + Webinar webinar = webinarRepository.findById(webinarId) + .orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru")); + if (!usesLiveKit(webinar)) { + return false; + } + try { + var response = roomClient() + .removeParticipant(liveKit.roomIdOf(webinar), identity) + .execute(); + if (response.isSuccessful()) { + log.info("Obecni: usunieto {} z pokoju webinaru {}", identity, webinarId); + return true; + } + // 404 to zwykle osoba, ktora zdazyla juz wyjsc sama + log.info("Obecni: serwer nie usunal {} z webinaru {} (kod {})", + identity, webinarId, response.code()); + return false; + } catch (Exception e) { + log.warn("Obecni: usuniecie {} z webinaru {} nieudane: {}", identity, webinarId, e.getMessage()); + return false; + } + } + + /** + * Czy pokoj stoi u LiveKit. + * + * Pokoj juz zalozony rozstrzyga `ofExistingRoom`: po zmianie dostawcy + * ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje + * wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu. + */ + private boolean usesLiveKit(Webinar webinar) { + VideoProvider existing = providerFactory.ofExistingRoom(webinar); + VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar); + return LiveKitVideoProvider.NAME.equals(provider.name()); + } + + private RoomServiceClient roomClient() { + return RoomServiceClient.createClient( + egressService.apiHttpUrl(), + settings.get(SettingKey.LIVEKIT_API_KEY), + settings.get(SettingKey.LIVEKIT_API_SECRET)); + } + + private List fromLiveKit(Webinar webinar) throws java.io.IOException { + var response = roomClient().listParticipants(liveKit.roomIdOf(webinar)).execute(); + if (response.code() == 404) { + // pokoj powstaje przy pierwszym wejsciu, wiec przed nim nikogo nie ma + return List.of(); + } + if (!response.isSuccessful() || response.body() == null) { + throw new IllegalStateException("kod " + response.code()); + } + + List infos = response.body().stream() + // nagrywanie (Egress) wchodzi jako ukryty uczestnik: to nie osoba + .filter(p -> !(p.hasPermission() + && (p.getPermission().getHidden() || p.getPermission().getRecorder()))) + .toList(); + + Map registrations = registrationRepository + .findAllById(idsWithPrefix(infos, "reg-")).stream() + .filter(r -> r.getWebinar().getId().equals(webinar.getId())) + .collect(Collectors.toMap(Registration::getId, Function.identity())); + Map speakers = speakerRepository + .findAllById(idsWithPrefix(infos, "spk-")).stream() + .collect(Collectors.toMap(WebinarSpeaker::getId, Function.identity())); + + List result = new ArrayList<>(); + for (LivekitModels.ParticipantInfo info : infos) { + boolean audio = false; + boolean video = false; + boolean screen = false; + for (LivekitModels.TrackInfo track : info.getTracksList()) { + if (track.getMuted()) continue; + switch (track.getSource()) { + case MICROPHONE, SCREEN_SHARE_AUDIO -> audio = true; + case CAMERA -> video = true; + case SCREEN_SHARE -> screen = true; + default -> { + if (track.getType() == LivekitModels.TrackType.AUDIO) audio = true; + if (track.getType() == LivekitModels.TrackType.VIDEO) video = true; + } + } + } + OffsetDateTime joinedAt = info.getJoinedAt() > 0 + ? OffsetDateTime.ofInstant(Instant.ofEpochSecond(info.getJoinedAt()), ZoneOffset.UTC) + : null; + + String identity = info.getIdentity(); + Long regId = idOf(identity, "reg-"); + Long spkId = idOf(identity, "spk-"); + Registration registration = regId == null ? null : registrations.get(regId); + WebinarSpeaker speaker = spkId == null ? null : speakers.get(spkId); + + if (registration != null) { + result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT, + registration.getId(), registration.getEmail(), joinedAt, audio, video, screen, + registration.isChatMuted(), registration.isRoomBanned())); + } else if (speaker != null) { + result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER, + null, speaker.getEmail(), joinedAt, audio, video, screen, false, false)); + } else { + // polaczenie spoza naszej sciezki wejscia: nie ma go w bazie + String name = info.getName().isBlank() ? identity : info.getName(); + result.add(new ParticipantView(identity, name, KIND_OTHER, + null, null, joinedAt, audio, video, screen, false, false)); + } + } + return result; + } + + private List fromAttendance(Webinar webinar) { + int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES)); + OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout); + + // jedna osoba moze miec kilka otwartych sesji (dwie karty), pokazujemy ja raz + Map unique = new HashMap<>(); + for (AttendanceSession session : attendanceRepository.findPresent(webinar.getId(), threshold)) { + Registration r = session.getRegistration(); + WebinarSpeaker s = session.getSpeaker(); + ParticipantView view; + if (r != null) { + view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT, + r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false, + r.isChatMuted(), r.isRoomBanned()); + } else if (s != null) { + view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER, + null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false); + } else { + continue; + } + unique.merge(view.identity(), view, (a, b) -> + a.joinedAt() != null && b.joinedAt() != null && b.joinedAt().isBefore(a.joinedAt()) ? b : a); + } + return new ArrayList<>(unique.values()); + } + + private static List filter(List all, String query) { + if (query == null || query.isBlank()) { + return all; + } + String needle = query.trim().toLowerCase(Locale.ROOT); + return all.stream() + .filter(p -> contains(p.name(), needle) || contains(p.email(), needle) + || contains(p.identity(), needle)) + .toList(); + } + + private static boolean contains(String value, String needle) { + return value != null && value.toLowerCase(Locale.ROOT).contains(needle); + } + + private static List idsWithPrefix(List infos, String prefix) { + return infos.stream() + .map(p -> idOf(p.getIdentity(), prefix)) + .filter(java.util.Objects::nonNull) + .toList(); + } + + /** Identyfikator z tozsamosci `reg-12` / `spk-3`, null gdy format sie nie zgadza. */ + static Long idOf(String identity, String prefix) { + if (identity == null || !identity.startsWith(prefix)) { + return null; + } + try { + return Long.parseLong(identity.substring(prefix.length())); + } catch (NumberFormatException e) { + return null; + } + } + + private static String fullName(Registration r) { + return ((r.getFirstName() == null ? "" : r.getFirstName()) + " " + + (r.getLastName() == null ? "" : r.getLastName())).trim(); + } + + // --- widoki --- + + /** + * @param source LIVEKIT (serwer pokoju) albo ATTENDANCE (sygnaly obecnosci) + * @param total wszyscy na sali, bez wzgledu na filtr + * @param problem opis bledu zrodla glownego, null gdy wszystko poszlo + */ + public record PresenceView(String source, int total, int speakers, int participants, int others, + List items, int page, int size, int totalPages, + long totalElements, List banned, String problem, + OffsetDateTime checkedAt) { + } + + /** + * Jedna osoba na sali. + * + * @param kind SPEAKER, PARTICIPANT albo OTHER (polaczenie spoza zapisu) + * @param registrationId wypelnione wylacznie dla uczestnika z zapisu + * @param audio nadaje dzwiek teraz, nie "ma prawo nadawac" + */ + public record ParticipantView(String identity, String name, String kind, Long registrationId, + String email, OffsetDateTime joinedAt, + boolean audio, boolean video, boolean screen, + boolean chatMuted, boolean roomBanned) { + } + + public record BannedView(Long registrationId, String name, String email) { + } +} diff --git a/frontend/src/pages/webinars/HostConsolePage.tsx b/frontend/src/pages/webinars/HostConsolePage.tsx index abd31a4..622f73d 100644 --- a/frontend/src/pages/webinars/HostConsolePage.tsx +++ b/frontend/src/pages/webinars/HostConsolePage.tsx @@ -9,6 +9,8 @@ import { useToasts } from '../../components/Toasts' import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui' import type { ModeratorInteractionView } from '../../lib/useInteractions' import { SpeakQueue, type SpeakRequestView } from './SpeakQueue' +import { RoomParticipants } from './RoomParticipants' +import { useConfirm } from '../../components/Confirm' import { panelPath } from '../../lib/routes' /** @@ -68,6 +70,8 @@ export function HostConsolePage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const toasts = useToasts() + const confirm = useConfirm() + const queryClient = useQueryClient() const [chat, setChat] = useState([]) const [questions, setQuestions] = useState([]) @@ -312,11 +316,24 @@ export function HostConsolePage() { registrationId: number, kind: 'mute' | 'ban', value: boolean, + authorName?: string, ) { + // blokada wyprowadza z sali, wiec zawsze pyta i nazywa osobe + if (kind === 'ban' && value) { + const ok = await confirm({ + title: `Zablokować ${authorName ?? 'uczestnika'}?`, + message: + 'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. ' + + 'Blokadę można zdjąć na liście „Zablokowani” w karcie „Na sali”.', + confirmLabel: 'Zablokuj', + }) + if (!ok) return + } try { await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, { value, }) + void queryClient.invalidateQueries({ queryKey: ['room-presence', id] }) const fresh = await initial.refetch() if (fresh.data) { setChat(fresh.data.chat) @@ -403,6 +420,7 @@ export function HostConsolePage() { onOpenVideoChange={setOpenVideo} onChanged={() => void loadSpeakQueue()} /> +
void - onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void + onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void }) { const bottomRef = useRef(null) useEffect(() => { @@ -509,7 +527,12 @@ function ChatColumn({ + )} + + + )} + + ))} + + + {data && data.banned.length > 0 && ( +
+ + {showBanned && ( +
    + {data.banned.map((person) => ( +
  • +
    +

    {person.name}

    +

    {person.email}

    +
    + +
  • + ))} +
+ )} +
+ )} + + + {data && data.totalPages > 1 && ( +
+ +
+ )} +
+ ) +}