Files
GmRelayBot/docs/plans/2026-05-04-player-list-kick-waitlist.md
T

12 KiB
Raw Blame History

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

public sealed record WebParticipant(
    Guid Id,
    long TelegramId,
    string DisplayName,
    string? TelegramUsername,
    string RsvpStatus,
    string RegistrationStatus,
    bool IsGm,
    DateTime? RespondedAt);

Step 2: Commit

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:

Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);

Step 2: Implement in SessionService

In SessionService.cs, add:

public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
{
    await using var conn = await dataSource.OpenConnectionAsync();
    return (await conn.QueryAsync<WebParticipant>(
        """
        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:

public async Task<List<WebParticipant>?> 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

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:

Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);

Step 2: Implement in SessionService

In SessionService.cs, add:

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<WebSession>(
        @"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<WebParticipant>(
        """
        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<WebPromotedParticipantDto>(
            """
            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,
        $"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
        parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);

    if (promoted is not null)
    {
        await bot.SendMessage(
            session.TelegramChatId,
            $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{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:

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

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

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

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

dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n

Step 4: Commit

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

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

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

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

git tag v1.9.7

git push origin main --tags

Step 3: Create release via MCP gitea_create_release.