# 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<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
```

**Step 2: Implement in SessionService**

In `SessionService.cs`, add:
```csharp
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:
```csharp
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**

```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<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:
```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.

---
