# Issue #15: Session Audit Log Implementation Plan

> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.

**Goal:** Add transparent audit history for every session change, visible to GM in Web Dashboard.

**Architecture:** PostgreSQL audit table + Dapper queries. Automatic logging inside mutating SessionService methods. New Razor page for GM history view.

**Tech Stack:** C# 12, Blazor SSR, Dapper, PostgreSQL, xUnit.

**Branch:** `issue-15-session-audit-log` (includes mobile UI bugfix cherry-pick)

**Version Bump:** `1.10.1` → `1.10.2`

---

## Task 1: Database Migration (V013)

**Objective:** Create `session_audit_log` table.

**File:** Create `src/GmRelay.Bot/Migrations/V013__add_session_audit_log.sql`

```sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

CREATE TABLE session_audit_log (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id          UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    actor_telegram_id   BIGINT NOT NULL,
    actor_name          VARCHAR(255) NOT NULL,
    change_type          VARCHAR(50) NOT NULL,
                        CHECK (change_type IN ('Title','Time','Link','MaxPlayers','Status','WaitlistPromote','PlayerRemoved','BatchRescheduled','Cancelled')),
    old_value           TEXT,
    new_value           TEXT,
    changed_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_session_audit_log_session_id ON session_audit_log(session_id);
CREATE INDEX ix_session_audit_log_changed_at ON session_audit_log(changed_at);
```

**Step 2:** Verify migration compiles: `psql $DATABASE_URL -f src/GmRelay.Bot/Migrations/V013__add_session_audit_log.sql` (optional, CI will test)

**Step 3:** Commit: `git add src/GmRelay.Bot/Migrations/V013__add_session_audit_log.sql && git commit -m "chore(#15): add session_audit_log migration"`

---

## Task 2: Domain Model & Interface Methods

**Objective:** Add `SessionAuditLogEntry` record and two new methods to `ISessionStore`.

**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`

**Step 1: Add record to ISessionStore.cs** (after PlayerAttendanceStats)

```csharp
public sealed record SessionAuditLogEntry(
    Guid Id,
    Guid SessionId,
    long ActorTelegramId,
    string ActorName,
    string ChangeType,
    string? OldValue,
    string? NewValue,
    DateTime ChangedAt
);
```

**Step 2: Add methods to ISessionStore interface**

```csharp
Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
```

**Step 3: Implement in SessionService.cs**

```csharp
public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
    using var connection = new NpgsqlConnection(connectionString);
    await connection.ExecuteAsync(
        "INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value) VALUES (@sessionId, @actorTelegramId, @actorName, @changeType, @oldValue, @newValue)",
        new { sessionId, actorTelegramId, actorName, changeType, oldValue, newValue });
}

public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
{
    using var connection = new NpgsqlConnection(connectionString);
    var entries = await connection.QueryAsync<SessionAuditLogEntry>(
        "SELECT id, session_id as SessionId, actor_telegram_id as ActorTelegramId, actor_name as ActorName, change_type as ChangeType, old_value as OldValue, new_value as NewValue, changed_at as ChangedAt FROM session_audit_log WHERE session_id = @sessionId ORDER BY changed_at DESC",
        new { sessionId });
    return entries.ToList();
}
```

**Step 4:** Commit: `git add -A && git commit -m "feat(#15): add SessionAuditLogEntry and audit store methods"`

---

## Task 3: Instrument Mutating Methods

**Objective:** Auto-log changes in key methods. Only log when value actually changes.

**File:** Modify `src/GmRelay.Web/Services/SessionService.cs`

**Step 1: Instrument UpdateSessionAsync**

Before executing UPDATE, read current session. After UPDATE, compare and log each changed field:

```csharp
// After var currentSession = await GetSessionAsync(sessionId);
// Before the UPDATE SQL:

if (currentSession is not null)
{
    if (currentSession.Title != title)
        await LogSessionChangeAsync(sessionId, /* actor will be passed from caller */ ...);
}
```

**Problem:** SessionService doesn't know the actor (GM) telegram ID. Solution: add `long actorTelegramId` parameter to mutating methods, or use a decorator pattern.

**Better approach:** Instrument in `AuthorizedSessionService` which already has `gmId`. That is the authorized wrapper.

**Revised approach:** Add `IAuditLogger` interface and inject it. Or simpler: change `ISessionStore` mutating methods to accept `long actorTelegramId` and `string actorName` as last parameters.

**Simpler approach for this plan:** Since all mutating methods in `ISessionStore` are called through `AuthorizedSessionService`, instrument in `AuthorizedSessionService` methods AFTER the underlying `SessionService` call. BUT we need old values. 

**Final approach:** Change `ISessionStore` methods to include `actorTelegramId` parameter, and inside `SessionService` fetch old values and log.

Let's add `long actorTelegramId` parameter to:
- `UpdateSessionAsync`
- `PromoteWaitlistedPlayerAsync`
- `RemovePlayerFromSessionAsync`
- `RescheduleBatchAsync`

Wait, this is a bigger change. Let's be more pragmatic: add a new `IAuditService` that can be called from `AuthorizedSessionService` after mutations. `IAuditService` just wraps `ISessionStore.LogSessionChangeAsync`.

Actually, simplest: add `actorTelegramId` and `actorName` parameters to `LogSessionChangeAsync` and call it from `AuthorizedSessionService` after each mutation. For old/new values, read the session before mutation in AuthorizedSessionService.

**Plan:**
- `AuthorizedSessionService.UpdateSessionForGmAsync`: read session before update, call update, then log differences.
- Same pattern for other mutating methods.

**Step 1: Read current session in AuthorizedSessionService.UpdateSessionForGmAsync**

```csharp
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
    var groupId = await EnforceSessionOwnershipAsync(sessionId, gmId);
    var before = await store.GetSessionAsync(sessionId);  // add this
    await store.UpdateSessionAsync(sessionId, groupId, title, scheduledAt, joinLink, maxPlayers);
    if (before is not null)
    {
        if (before.Title != title)
            await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", before.Title, title);
        if (before.ScheduledAt != scheduledAt)
            await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", before.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
        if (before.JoinLink != joinLink)
            await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", before.JoinLink, joinLink);
        if (before.MaxPlayers != maxPlayers)
            await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", before.MaxPlayers?.ToString(), maxPlayers?.ToString());
    }
}
```

Wait, `EnforceSessionOwnershipAsync` returns `groupId` but we need it. Let me check the current AuthorizedSessionService code... We saw earlier it does `await EnforceSessionOwnershipAsync(sessionId, gmId)` then calls `store.UpdateSessionAsync`.

Actually, looking at the compact output from earlier, `AuthorizedSessionService` has `EnforceSessionOwnershipAsync` as a private helper. Let me verify.

From the compact output of AuthorizedSessionService earlier:
```
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
    var groupId = await EnforceSessionOwnershipAsync(sessionId, gmId);
    await store.UpdateSessionAsync(sessionId, groupId, title, scheduledAt, joinLink, maxPlayers);
}
```

So it DOES return groupId. Perfect.

But we need actor name. We can use the GM's display name from `GetGroupManagementForGmAsync` or just hardcode "ГМ" for now. Or we can query the player name. For simplicity, use "ГМ" + telegram ID, or query the player display name.

Actually, let's query the GM name from the group or just use telegram ID as identifier. Since this is audit log, having human-readable name is better. We can pass `actorName` to the log method.

Simpler: In `AuthorizedSessionService`, after `EnforceSessionOwnershipAsync`, we already know the group. We can get the GM name from group or just use a generic "ГМ" label. Actually, the telegram ID is sufficient for audit, and the display name can be resolved later in the UI.

Let's just log `actor_telegram_id` and use "ГМ" as actor_name. Or better, query the player display name if needed.

For this plan, let's keep it simple: log with `gmId` as actor_telegram_id and `"ГМ"` as actor_name. The UI can resolve the name.

**Step 2: Instrument all mutating AuthorizedSessionService methods**

Do this for:
- `UpdateSessionForGmAsync` (Title, Time, Link, MaxPlayers)
- `RescheduleBatchForGmAsync` (BatchRescheduled)
- `PromoteWaitlistedPlayerForGmAsync` (WaitlistPromote)
- `RemovePlayerFromSessionForGmAsync` (PlayerRemoved)
- `UpdateBatchDetailsForGmAsync` (Title, Link)
- `UpdateBatchNotificationModeForGmAsync` (maybe skip, internal)
- `DeleteSessionHandler` in Bot features (Cancelled) — this is in Bot layer, not Web

For Cancelled status, we need to instrument `CancelSessionHandler` in Bot. But the Bot handlers don't use `ISessionStore` directly... they use `SessionService` or direct SQL. Let me check.

Actually, for this plan, focus on Web layer mutations first. Bot layer can be a follow-up.

**Step 3: Commit:** `git add -A && git commit -m "feat(#15): instrument audit logging in AuthorizedSessionService"`

---

## Task 4: Web Page - SessionHistory.razor

**Objective:** GM-visible timeline of session changes.

**Files:**
- Create: `src/GmRelay.Web/Components/Pages/SessionHistory.razor`
- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor` (add "История" link)
- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor` (add "История" link)

**Step 1: Create SessionHistory.razor**

Route: `@page "/session/{SessionId:guid}/history"`

Layout: table with columns: Время, Актор, Тип изменения, Было, Стало.

Colors: use existing CSS variables.

**Step 2: Add navigation links**

In GroupDetails.razor sessions table: add 📜 История column/link.
In EditSession.razor: add button "📜 История изменений" linking to `/session/{SessionId}/history`.

**Step 3: Commit:** `git add -A && git commit -m "feat(#15): add SessionHistory.razor page with audit timeline"`

---

## Task 5: FakeSessionStore + TDD Tests

**Objective:** Test audit logging. RED-GREEN-REFACTOR.

**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs`

**Step 1: RED — Write failing test**

```csharp
[Fact]
public async Task UpdateSessionForGmAsync_LogsAudit_WhenTitleChanges()
{
    var gmId = 1001L;
    var groupId = Guid.NewGuid();
    var sessionId = Guid.NewGuid();
    var store = new FakeSessionStore(
        groups: [new(groupId, 42, "Alpha", gmId)],
        sessions: [new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)]);
    var service = new AuthorizedSessionService(store);

    await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);

    Assert.Single(store.LogEntries);
    Assert.Equal("Title", store.LogEntries[0].ChangeType);
    Assert.Equal("Session A", store.LogEntries[0].OldValue);
    Assert.Equal("Updated Title", store.LogEntries[0].NewValue);
}
```

Run: `dotnet test tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs --filter "FullyQualifiedName~LogsAudit" -v n`
Expected: FAIL (FakeSessionStore doesn't have LogSessionChangeAsync / LogEntries)

**Step 2: GREEN — Add to FakeSessionStore**

Add `List<SessionAuditLogEntry> LogEntries` and implement `LogSessionChangeAsync` / `GetSessionHistoryAsync`.

**Step 3: Refactor — Add more tests**

- No audit when values unchanged
- Audit for time change
- Audit for link change
- Audit for max players change
- GetSessionHistoryAsync returns entries ordered by time desc

**Step 4: Full suite**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
Expected: all pass

**Step 5: Commit:** `git add -A && git commit -m "test(#15): add audit logging TDD tests"`

---

## Task 6: Bump Version & Docs

**Objective:** Update version and documentation.

**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `README.md`
- Modify: Wiki «Руководство ГМа»

**Step 1: Bump version to 1.10.2**

**Step 2: Update README** — add "📜 История изменений сессии" bullet in Web Dashboard section.

**Step 3: Update wiki** — add subsection «Журнал действий» with description of how to access `/session/{id}/history`, what change types are tracked, and how to read the timeline.

**Step 4: Commit:** `git add -A && git commit -m "docs(#15): bump version to 1.10.2 and add audit log docs"`

---

## Task 7: Push & PR

**Step 1:** `git push origin issue-15-session-audit-log`

**Step 2:** Create PR via `mcp_gitea_pull_request_write`

**Step 3:** Wait CI (`mcp_gitea_actions_run_read`)

**Step 4:** Merge PR

**Step 5:** Create tag `v1.10.2` and release

---

## Verification Checklist

- [ ] Migration V013 applied successfully
- [ ] `SessionAuditLogEntry` record defined in `ISessionStore.cs`
- [ ] `LogSessionChangeAsync` and `GetSessionHistoryAsync` implemented in `SessionService.cs`
- [ ] `AuthorizedSessionService` logs on value changes only
- [ ] `SessionHistory.razor` renders timeline for GM
- [ ] Links from GroupDetails and EditSession pages
- [ ] `FakeSessionStore` implements audit methods
- [ ] TDD tests: RED → GREEN → all pass
- [ ] Full `dotnet test` suite: all pass
- [ ] Version bumped to 1.10.2
- [ ] README and wiki updated
- [ ] CI run success
- [ ] PR merged to main
- [ ] Tag `v1.10.2` created
- [ ] Release published
