# Player List + Kick + Waitlist Promotion Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player. **Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal. **Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL --- ## Task 1: Add domain model for WebParticipant **Objective:** Create a DTO to represent a session participant in the web layer. **Files:** - Modify: `src/GmRelay.Web/Services/SessionService.cs` **Step 1: Add record** ```csharp public sealed record WebParticipant( Guid Id, long TelegramId, string DisplayName, string? TelegramUsername, string RsvpStatus, string RegistrationStatus, bool IsGm, DateTime? RespondedAt); ``` **Step 2: Commit** ```bash git add src/GmRelay.Web/Services/SessionService.cs git commit -m "feat: add WebParticipant record" ``` --- ## Task 2: Add GetSessionParticipantsAsync to ISessionStore **Objective:** Retrieve all participants for a session with full player info. **Files:** - Modify: `src/GmRelay.Web/Services/ISessionStore.cs` - Modify: `src/GmRelay.Web/Services/SessionService.cs` - Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs` **Step 1: Add to interface** In `ISessionStore.cs`, add: ```csharp Task> GetSessionParticipantsAsync(Guid sessionId); ``` **Step 2: Implement in SessionService** In `SessionService.cs`, add: ```csharp public async Task> GetSessionParticipantsAsync(Guid sessionId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT sp.id AS Id, p.telegram_id AS TelegramId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, sp.rsvp_status AS RsvpStatus, sp.registration_status AS RegistrationStatus, sp.is_gm AS IsGm, sp.responded_at AS RespondedAt FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId ORDER BY sp.is_gm DESC, CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END, sp.created_at """, new { SessionId = sessionId })).ToList(); } ``` **Step 3: Add authorized wrapper** In `AuthorizedSessionService.cs`, add: ```csharp public async Task?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) { var session = await GetSessionForGmAsync(sessionId, gmId); if (session is null) { return null; } return await sessionStore.GetSessionParticipantsAsync(sessionId); } ``` **Step 4: Commit** ```bash git add src/GmRelay.Web/Services/ISessionStore.cs git add src/GmRelay.Web/Services/SessionService.cs git add src/GmRelay.Web/Services/AuthorizedSessionService.cs git commit -m "feat: add GetSessionParticipantsAsync" ``` --- ## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion **Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met. **Files:** - Modify: `src/GmRelay.Web/Services/ISessionStore.cs` - Modify: `src/GmRelay.Web/Services/SessionService.cs` - Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs` **Step 1: Add to interface** In `ISessionStore.cs`, add: ```csharp Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); ``` **Step 2: Implement in SessionService** In `SessionService.cs`, add: ```csharp public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var session = await conn.QuerySingleOrDefaultAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, 0 AS ActivePlayerCount, 0 AS WaitlistedPlayerCount, s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND s.group_id = @GroupId FOR UPDATE", new { SessionId = sessionId, GroupId = groupId }, transaction); if (session is null) { throw new SessionAccessDeniedException(sessionId, 0); } // Verify participant exists in this session var participant = await conn.QuerySingleOrDefaultAsync( """ SELECT sp.id AS Id, p.telegram_id AS TelegramId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, sp.rsvp_status AS RsvpStatus, sp.registration_status AS RegistrationStatus, sp.is_gm AS IsGm, sp.responded_at AS RespondedAt FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId """, new { ParticipantId = participantId, SessionId = sessionId }, transaction); if (participant is null) { throw new InvalidOperationException("Участник не найден в этой сессии."); } bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active; await conn.ExecuteAsync( "DELETE FROM session_participants WHERE id = @ParticipantId", new { ParticipantId = participantId }, transaction); WebPromotedParticipantDto? promoted = null; if (wasActive) { promoted = await conn.QuerySingleOrDefaultAsync( """ SELECT sp.id AS ParticipantRowId, p.display_name AS DisplayName FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Waitlisted ORDER BY sp.created_at ASC, sp.id ASC LIMIT 1 FOR UPDATE OF sp """, new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, transaction); if (promoted is not null) { await conn.ExecuteAsync( """ UPDATE session_participants SET registration_status = @Active, rsvp_status = @Pending, responded_at = NULL WHERE id = @ParticipantRowId """, new { promoted.ParticipantRowId, Active = ParticipantRegistrationStatus.Active, Pending = RsvpStatus.Pending }, transaction); } } await transaction.CommitAsync(); // Notifications await bot.SendMessage( session.TelegramChatId, $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); if (promoted is not null) { await bot.SendMessage( session.TelegramChatId, $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); } if (session.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); } } ``` **Step 3: Add authorized wrapper** In `AuthorizedSessionService.cs`, add: ```csharp public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) { var session = await GetSessionForGmAsync(sessionId, gmId); if (session is null) { throw new SessionAccessDeniedException(sessionId, gmId); } await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); } ``` **Step 4: Commit** ```bash git add src/GmRelay.Web/Services/ISessionStore.cs git add src/GmRelay.Web/Services/SessionService.cs git add src/GmRelay.Web/Services/AuthorizedSessionService.cs git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion" ``` --- ## Task 4: Modify GroupDetails.razor to show participant list **Objective:** Add expandable player lists to each session row with kick buttons. **Files:** - Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor` **Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables. **Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods. **Step 3:** In desktop table, add a new column or expand row with participant list. **Step 4:** In mobile cards, add expandable participant section. **Step 5:** Add styles to `app.css` if needed (badge styles are already present). **Step 6:** Commit ```bash git add src/GmRelay.Web/Components/Pages/GroupDetails.razor git add src/GmRelay.Web/wwwroot/app.css git commit -m "feat: show player list and kick button in GroupDetails" ``` --- ## Task 5: Modify EditSession.razor to show participant list **Objective:** Show participant list on the edit page with kick capability. **Files:** - Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor` **Step 1:** Load participants in `OnInitializedAsync`. **Step 2:** Render participant list below the edit form. **Step 3:** Add kick button for each non-GM participant. **Step 4:** Commit ```bash git add src/GmRelay.Web/Components/Pages/EditSession.razor git commit -m "feat: show player list and kick button in EditSession" ``` --- ## Task 6: Add backend tests **Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic. **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs` **Step 1:** Write tests for `GetSessionParticipantsForGmAsync`. **Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion. **Step 3:** Run tests ```bash dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n ``` **Step 4:** Commit ```bash git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs git commit -m "test: add SessionParticipant service tests" ``` --- ## Task 7: Update README **Objective:** Bump version and document new features. **Files:** - Modify: `README.md` **Step 1:** Change version from `v1.9.6` to `v1.9.7`. **Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote. **Step 3:** Commit ```bash git add README.md git commit -m "docs: bump README to v1.9.7, document player list kick" ``` --- ## Task 8: Update Wiki **Objective:** Update `Руководство ГМа` page with player management instructions. **Files:** - Modify: Wiki page `Руководство ГМа` **Step 1:** Read current wiki content via MCP. **Step 2:** Add section about viewing player list and removing players. **Step 3:** Update via MCP. --- ## Task 9: Push branch and run CI **Objective:** Push branch, monitor workflow, fix issues. **Step 1:** Push ```bash git push -u origin feat/player-list-kick-waitlist ``` **Step 2:** Check workflow run via MCP gitea actions. **Step 3:** Fix any issues. --- ## Task 10: Merge and create release **Objective:** Merge PR (or fast-forward), tag, create release. **Step 1:** Merge to main ```bash git checkout main git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)" ``` **Step 2:** Tag v1.9.7 ```bash git tag v1.9.7 git push origin main --tags ``` **Step 3:** Create release via MCP gitea_create_release. ---