Panel prowadzenia: lista osob na sali i usuwanie po stronie serwera
Deploy PROD / deploy (push) Successful in 26s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:39:45 +02:00
parent 4f21af5f80
commit e9bb4af59f
10 changed files with 930 additions and 9 deletions
+22
View File
@@ -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.
@@ -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());
}
@@ -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.");
}
@@ -50,6 +50,24 @@ public interface AttendanceSessionRepository extends JpaRepository<AttendanceSes
""")
List<AttendanceSession> 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<AttendanceSession> findPresent(@Param("webinarId") Long webinarId,
@Param("threshold") OffsetDateTime threshold);
/**
* Laczny czas obecnosci jednego uczestnika, w sekundach.
*
@@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository<Registration, Long
List<Registration> findByWebinarIdOrderByCreatedAtDesc(Long webinarId);
/** Zablokowani przez prowadzacego, do odblokowania z panelu prowadzenia. */
List<Registration> findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(Long webinarId);
List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
/*
@@ -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()) {
@@ -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) {
}
}
@@ -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<ParticipantView> all;
String source;
String problem = null;
if (usesLiveKit(webinar)) {
source = SOURCE_LIVEKIT;
List<ParticipantView> 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<ParticipantView> 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<ParticipantView> items = filtered.subList(
Math.min(from, filtered.size()), Math.min(from + safeSize, filtered.size()));
List<BannedView> 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<ParticipantView> 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<LivekitModels.ParticipantInfo> 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<Long, Registration> registrations = registrationRepository
.findAllById(idsWithPrefix(infos, "reg-")).stream()
.filter(r -> r.getWebinar().getId().equals(webinar.getId()))
.collect(Collectors.toMap(Registration::getId, Function.identity()));
Map<Long, WebinarSpeaker> speakers = speakerRepository
.findAllById(idsWithPrefix(infos, "spk-")).stream()
.collect(Collectors.toMap(WebinarSpeaker::getId, Function.identity()));
List<ParticipantView> 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<ParticipantView> 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<String, ParticipantView> 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<ParticipantView> filter(List<ParticipantView> 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<Long> idsWithPrefix(List<LivekitModels.ParticipantInfo> 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<ParticipantView> items, int page, int size, int totalPages,
long totalElements, List<BannedView> 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) {
}
}
@@ -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<ModeratorInteractionView[]>([])
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
@@ -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()}
/>
<RoomParticipants webinarId={id ?? ''} timezone={webinar.data.timezone} />
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
@@ -459,7 +477,7 @@ function ChatColumn({
}: {
messages: ModeratorInteractionView[]
onReject: (id: number) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
}) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -509,7 +527,12 @@ function ChatColumn({
<button
type="button"
onClick={() =>
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
onModerate(
message.authorRegistrationId!,
'ban',
!message.authorRoomBanned,
message.authorName,
)
}
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
@@ -0,0 +1,350 @@
import { useEffect, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import { Badge, Input } from '../../components/ui'
import { formatTimeOnly, plural } from '../../lib/dates'
/** Jedna osoba na sali, tak jak zwraca `RoomPresenceService.ParticipantView`. */
type Participant = {
identity: string
name: string
kind: 'SPEAKER' | 'PARTICIPANT' | 'OTHER'
registrationId: number | null
email: string | null
joinedAt: string | null
audio: boolean
video: boolean
screen: boolean
chatMuted: boolean
roomBanned: boolean
}
type Presence = {
source: 'LIVEKIT' | 'ATTENDANCE'
total: number
speakers: number
participants: number
others: number
items: Participant[]
page: number
size: number
totalPages: number
totalElements: number
banned: { registrationId: number; name: string; email: string }[]
problem: string | null
checkedAt: string
}
/**
* Kto jest teraz na sali.
*
* ODPYTYWANIE, NIE WEBSOCKET
* ------------------------------------------------------------------------
* Wejscie i wyjscie z pokoju widzi serwer wideo, nie nasz backend: nie ma
* zdarzenia, ktore moglibysmy rozeslac. Lista jest wiec pobierana co dziesiec
* sekund. Dokladnosc do kilku sekund wystarcza, a jedno lekkie zapytanie na
* prowadzacego nie obciaza niczego.
*
* USUNIECIE TO BLOKADA
* ------------------------------------------------------------------------
* Samo wyrzucenie nic nie daje, bo uczestnik wrocilby tym samym linkiem.
* Dlatego "Usun z sali" blokuje zapis i wyrzuca polaczenie po stronie serwera.
* Blokade zdejmuje sie z listy "Zablokowani" na dole karty. Operacja jest
* odwracalna, wiec potwierdzenie nie jest oznaczone jako nieodwracalne.
*/
export function RoomParticipants({ webinarId, timezone }: { webinarId: string; timezone: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [page, setPage] = useState(0)
const [showBanned, setShowBanned] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
// wyszukiwanie po chwili bezruchu, zeby nie odpytywac serwera przy kazdej literze
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(search.trim())
// zmiana frazy cofa na pierwsza strone, tak jak kazdy filtr
setPage(0)
}, 300)
return () => window.clearTimeout(timer)
}, [search])
const presence = useQuery({
queryKey: ['room-presence', webinarId, query, page],
queryFn: async () =>
(
await client.get<Presence>(`/admin/webinars/${webinarId}/room/participants`, {
params: { q: query || undefined, page, size: PAGE_SIZE },
})
).data,
refetchInterval: 10_000,
placeholderData: keepPreviousData,
})
const refresh = () => queryClient.invalidateQueries({ queryKey: ['room-presence', webinarId] })
async function remove(participant: Participant) {
const fromRegistration = participant.kind === 'PARTICIPANT'
const ok = await confirm({
title: `Usunąć ${participant.name} z sali?`,
message: fromRegistration
? 'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. '
+ 'Straci też możliwość pisania na czacie. Blokadę można zdjąć w tym panelu, '
+ 'na liście „Zablokowani”.'
: 'To połączenie nie pochodzi z zapisu na webinar, więc nie da się go zablokować. '
+ 'Zostanie rozłączone, ale może wrócić, jeśli ma własny dostęp do serwera wideo.',
confirmLabel: 'Usuń z sali',
})
if (!ok) return
setBusy(participant.identity)
try {
const { data } = await client.post<{ banned: boolean; removedFromRoom: boolean }>(
`/admin/webinars/${webinarId}/room/participants/remove`,
{ identity: participant.identity },
)
if (data.removedFromRoom) {
toasts.success(`Usunięto z sali: ${participant.name}`)
} else if (data.banned) {
toasts.success(`Zablokowano: ${participant.name}. Przeglądarka tej osoby opuści salę.`)
} else {
toasts.success('Tej osoby nie było już na sali')
}
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się usunąć z sali'))
} finally {
setBusy(null)
}
}
async function toggleChat(participant: Participant) {
if (participant.registrationId === null) return
setBusy(participant.identity)
try {
await client.post(
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
{ value: !participant.chatMuted },
)
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Wyciszono na czacie')
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
} finally {
setBusy(null)
}
}
async function unban(registrationId: number, name: string) {
setBusy(`ban-${registrationId}`)
try {
await client.post(
`/admin/webinars/${webinarId}/interactions/participants/${registrationId}/ban`,
{ value: false },
)
toasts.success(`Odblokowano: ${name}. Może ponownie wejść na salę.`)
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się odblokować'))
} finally {
setBusy(null)
}
}
const data = presence.data
return (
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="border-b border-slate-200 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">Na sali</h2>
<span className="hx-data text-sm font-semibold text-slate-900">
{data ? data.total : '...'}
<span className="ml-1 font-sans text-xs font-normal text-slate-500">
{data ? plural(data.total, 'osoba', 'osoby', 'osób') : ''}
</span>
</span>
</div>
{data && (
<p className="mt-0.5 text-xs text-slate-500">
Prowadzący: <span className="hx-data">{data.speakers}</span>
{' · '}Uczestnicy: <span className="hx-data">{data.participants}</span>
{data.others > 0 && (
<>
{' · '}Spoza zapisu: <span className="hx-data">{data.others}</span>
</>
)}
</p>
)}
<div className="relative mt-2">
<Icon
name="search"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Szukaj po nazwisku lub adresie"
aria-label="Szukaj osoby na sali"
className="pl-9"
/>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
{data?.problem && <p className="bg-warn-50 px-4 py-2 text-xs text-warn-600">{data.problem}</p>}
{data?.source === 'ATTENDANCE' && !data.problem && (
<p className="bg-slate-50 px-4 py-2 text-xs text-slate-500">
Lista według sygnałów obecności ze strony spotkania, odświeżanych co minutę.
</p>
)}
{presence.isPending && <p className="py-6 text-center text-sm text-slate-400">Wczytywanie...</p>}
{presence.isError && (
<p className="py-6 text-center text-sm text-alarm-600">
{errorMessage(presence.error, 'Nie udało się wczytać listy obecnych')}
</p>
)}
{data && data.items.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
{query ? 'Nikt na sali nie pasuje do wyszukiwania.' : 'Na sali nikogo jeszcze nie ma.'}
</p>
)}
<ul className="divide-y divide-slate-100">
{data?.items.map((participant) => (
<li key={participant.identity} className="flex items-center gap-2 px-4 py-2 text-sm">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-medium text-slate-800">{participant.name}</span>
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
{participant.chatMuted && <Badge tone="warn">wyciszony</Badge>}
</div>
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
{participant.email && <span className="truncate">{participant.email}</span>}
{participant.joinedAt && (
<span className="hx-data">od {formatTimeOnly(participant.joinedAt, timezone)}</span>
)}
</div>
</div>
{/* co osoba NADAJE w tej chwili, nie co ma prawo nadawac */}
<div className="flex shrink-0 items-center gap-1 text-slate-400">
{participant.audio && (
<span title="Mówi (mikrofon włączony)" className="text-ok-600">
<Icon name="mic" size={14} />
<span className="sr-only">mikrofon włączony</span>
</span>
)}
{participant.video && (
<span title="Kamera włączona" className="text-ok-600">
<Icon name="video" size={14} />
<span className="sr-only">kamera włączona</span>
</span>
)}
{participant.screen && (
<span title="Udostępnia ekran" className="text-ok-600">
<Icon name="presentation" size={14} />
<span className="sr-only">udostępnia ekran</span>
</span>
)}
</div>
{participant.kind !== 'SPEAKER' && (
<div className="flex shrink-0 items-center gap-0.5">
{participant.kind === 'PARTICIPANT' && (
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void toggleChat(participant)}
aria-label={
participant.chatMuted
? `Przywróć pisanie na czacie: ${participant.name}`
: `Wycisz na czacie: ${participant.name}`
}
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Wycisz na czacie'}
className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40"
>
<Icon name={participant.chatMuted ? 'message-square' : 'mic-off'} size={16} />
</button>
)}
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void remove(participant)}
aria-label={`Usuń z sali: ${participant.name}`}
title="Usuń z sali"
className="flex h-9 w-9 items-center justify-center rounded-md text-alarm-600 hover:bg-alarm-50 disabled:opacity-40"
>
<Icon name="user-off" size={16} />
</button>
</div>
)}
</li>
))}
</ul>
{data && data.banned.length > 0 && (
<div className="border-t border-slate-200">
<button
type="button"
onClick={() => setShowBanned((value) => !value)}
aria-expanded={showBanned}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<span>
Zablokowani: <span className="hx-data">{data.banned.length}</span>
</span>
<Icon name={showBanned ? 'chevron-up' : 'chevron-down'} size={14} />
</button>
{showBanned && (
<ul className="divide-y divide-slate-100">
{data.banned.map((person) => (
<li key={person.registrationId} className="flex items-center gap-2 px-4 py-2 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate text-slate-700">{person.name}</p>
<p className="truncate text-xs text-slate-500">{person.email}</p>
</div>
<button
type="button"
disabled={busy === `ban-${person.registrationId}`}
onClick={() => void unban(person.registrationId, person.name)}
className="flex min-h-9 shrink-0 items-center gap-1 rounded-md px-2 text-xs font-medium text-blue-700 hover:bg-blue-50 disabled:opacity-40"
>
<Icon name="user-plus" size={14} />
Odblokuj
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
{data && data.totalPages > 1 && (
<div className="border-t border-slate-200 px-3 py-2">
<Pagination
page={data.page}
totalPages={data.totalPages}
totalElements={data.totalElements}
pageSize={data.size}
onChange={setPage}
itemLabel="osób"
/>
</div>
)}
</section>
)
}