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
@@ -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) {
}
}