14 KiB
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
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)
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
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
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:
// 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:
UpdateSessionAsyncPromoteWaitlistedPlayerAsyncRemovePlayerFromSessionAsyncRescheduleBatchAsync
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
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)DeleteSessionHandlerin 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
[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
SessionAuditLogEntryrecord defined inISessionStore.csLogSessionChangeAsyncandGetSessionHistoryAsyncimplemented inSessionService.csAuthorizedSessionServicelogs on value changes onlySessionHistory.razorrenders timeline for GM- Links from GroupDetails and EditSession pages
FakeSessionStoreimplements audit methods- TDD tests: RED → GREEN → all pass
- Full
dotnet testsuite: all pass - Version bumped to 1.10.2
- README and wiki updated
- CI run success
- PR merged to main
- Tag
v1.10.2created - Release published