Compare commits

..

2 Commits

Author SHA1 Message Date
root 7c737dbf45 fix(tests): update TelegramLandingSmoke to use SessionBatchViewBuilder + TelegramSessionBatchRenderer 2026-05-06 08:25:07 +00:00
root 52b7b3e0fd refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer
- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Все вызовы обновлены на новую цепочку ViewBuilder → TelegramRenderer
2026-05-06 07:57:23 +00:00
40 changed files with 1004 additions and 1906 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.10.5
VERSION: 1.9.9
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
-33
View File
@@ -1,33 +0,0 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build Shared
run: dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
- name: Build Bot (compile check)
run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
- name: Build Web (compile check)
run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
- name: Run tests
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
-343
View File
@@ -1,343 +0,0 @@
# 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
-480
View File
@@ -1,480 +0,0 @@
# Issue #19: выровнять /newsession с batch-сценарием лендинга — Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Убедиться, что Telegram UX `/newsession` полностью соответствует batch-сценарию лендинга: мастер одной командой создаёт несколько дат, указывает лимит мест и ссылку, получает единую карточку с действиями. Обеспечить покрытие acceptance criteria регрессионными тестами и устранить найденные расхождения.
**Architecture:** Сценарий уже реализован в `CreateSessionHandler` + `NewSessionCommandParser` + `SessionBatchRenderer`. Основная задача — добавить недостающие тесты на «точный» landing-сценарий (несколько явных дат, не recurring), проверить отсутствие частичных сессий при любых ошибках, и убедиться, что карточка содержит все обещанные элементы.
**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Telegram.Bot, Native AOT.
---
## Контекст кодовой базы
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs` — создание batch, транзакция БД, отправка карточки.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs` — парсинг команды: несколько `Время:`, `Мест:`, `Ссылка:`, `Картинка:`, recurring (`Игр:` + `Интервал:`).
- `src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs` — рендеринг HTML-карточки с кнопками.
- `src/GmRelay.Shared/Rendering/BatchMessageEditor.cs` — редактирование batch-сообщения (text/photo).
- `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — роутинг команд, текст `/help`.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs` — тесты парсера.
- `tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs` — smoke-тест всего landing-флоу через `FakeTelegramMessenger`.
- `tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs` — тесты рендерера.
---
### Task 1: RED — тест landing-парсинга с несколькими явными датами
**Objective:** Проверить, что парсер корректно обрабатывает точный формат из лендинга: 2+ явных даты, лимит мест, ссылка, без recurring.
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs`
**Step 1: Write failing test**
```csharp
[Fact]
public void Parse_ShouldHandleLandingBatchWithMultipleExplicitDates()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Landing Batch Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://example.test/landing
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Landing Batch Game", result.Title);
Assert.Equal("https://example.test/landing", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(2, result.ScheduledTimes.Count);
Assert.Equal(new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero), result.ScheduledTimes[0]);
Assert.Equal(new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero), result.ScheduledTimes[1]);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
Assert.Empty(result.InvalidSeatLimitInputs);
Assert.Empty(result.InvalidRecurringInputs);
}
```
**Step 2: Run test to verify failure**
Run: `dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Parse_ShouldHandleLandingBatchWithMultipleExplicitDates" -v n`
Expected: PASS (функциональность уже реализована, но теста не было). Если FAIL — исправить парсер перед продолжением.
**Step 3: Commit**
```bash
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
git commit -m "test(#19): landing batch parser test with multiple explicit dates"
```
---
### Task 2: RED — тест рендеринга landing-карточки
**Objective:** Проверить, что `SessionBatchRenderer` для landing-сценария выводит название, все даты, лимит, заполненность и кнопки записи/выхода.
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs`
**Step 1: Write failing test**
```csharp
[Fact]
public void Render_ShouldProduceLandingBatchCardWithAllRequiredElements()
{
var sessionId1 = Guid.NewGuid();
var sessionId2 = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(sessionId1, new DateTime(2026, 5, 15, 16, 30, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(sessionId2, new DateTime(2026, 5, 22, 16, 30, 0, DateTimeKind.Utc), SessionStatus.Planned, 4)
};
var participants = new[]
{
new ParticipantBatchDto(sessionId1, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId1, "Bob", null, ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId2, "Charlie", "charlie", ParticipantRegistrationStatus.Waitlisted)
};
var result = SessionBatchRenderer.Render("Landing Batch Game", sessions, participants);
var text = result.Text;
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Contains("Landing Batch Game", text);
Assert.Contains("15 мая 2026, 19:30", text);
Assert.Contains("22 мая 2026, 19:30", text);
Assert.Contains("Места: 2/4", text);
Assert.Contains("Места: 0/4", text);
Assert.Contains("@alice", text);
Assert.Contains("Bob", text);
Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("@charlie", text);
Assert.Equal(4, buttons.Count);
Assert.Contains($"join_session:{sessionId1}", buttons.Select(b => b.CallbackData));
Assert.Contains($"leave_session:{sessionId1}", buttons.Select(b => b.CallbackData));
Assert.Contains($"join_session:{sessionId2}", buttons.Select(b => b.CallbackData));
Assert.Contains($"leave_session:{sessionId2}", buttons.Select(b => b.CallbackData));
}
```
**Step 2: Run test to verify failure**
Run: `dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Render_ShouldProduceLandingBatchCardWithAllRequiredElements" -v n`
Expected: PASS (рендерер уже реализован, тест отсутствовал).
**Step 3: Commit**
```bash
git add tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs
git commit -m "test(#19): landing batch renderer card elements test"
```
---
### Task 3: RED — тест «ошибки ввода не создают частичных сессий»
**Objective:** Убедиться, что при невалидном вводе `CreateSessionHandler` не создаёт ни одной записи в БД и не публикует карточку.
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs` (если найдена уязвимость)
**Step 1: Write failing test**
```csharp
using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql;
using Telegram.Bot;
using Microsoft.Extensions.Logging.Abstractions;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class CreateSessionHandlerTests
{
// Примечание: полноценный интеграционный тест с реальной БД требует TestContainer.
// Для Native AOT проекта используем подход с in-memory фейком через рефакторинг хендлера.
// Ниже — тест-спецификация, которую реализуем через FakeDataSource или рефакторинг.
}
```
Поскольку `CreateSessionHandler` напрямую зависит от `NpgsqlDataSource` и `ITelegramBotClient`, для unit-тестирования нужно либо:
а) использовать интеграционный тест с PostgreSQL (TestContainers), либо
б) рефакторить хендлер, выделив `ISessionRepository`.
**Рекомендуемый подход (YAGNI):** добавить интеграционный тест в smoke-стиле через `FakeTelegramMessenger`, дополнив `TelegramLandingSmokeScenario` сценарием «invalid command does not publish anything».
**Step 1 (реализация):** Дописать тест в `TelegramLandingPromisesSmokeTests.cs`:
```csharp
[Fact]
public void Smoke_InvalidNewSession_ShouldNotPublishAnySessions()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var invalidText = """
/newsession
Название: Bad Game
Время: 01.01.2020 19:30
"""; // нет ссылки
var parseResult = NewSessionCommandParser.Parse(invalidText, nowUtc);
Assert.False(parseResult.IsValid);
Assert.Empty(parseResult.ScheduledTimes);
// Убеждаемся, что smoke-сценарий не может быть опубликован
Assert.Throws<InvalidOperationException>(() =>
TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect));
}
```
В `TelegramLandingSmokeScenario.Publish` добавить guard:
```csharp
if (!parseResult.IsValid)
throw new InvalidOperationException("Cannot publish invalid parse result");
```
**Step 2: Run test to verify failure**
Run: `dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Smoke_InvalidNewSession_ShouldNotPublishAnySessions" -v n`
Expected: FAIL — guard ещё не добавлен.
**Step 3: Write minimal implementation**
Добавить guard в `TelegramLandingSmokeScenario.Publish`:
```csharp
if (!parseResult.IsValid)
throw new InvalidOperationException("Cannot publish invalid parse result");
```
**Step 4: Run test to verify pass**
Expected: PASS.
**Step 5: Commit**
```bash
git add tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs
git add src/GmRelay.Bot/... # если были изменения
# (файл CreateSessionHandler.cs не трогаем — валидация происходит ДО транзакции)
git commit -m "test(#19): ensure invalid parse does not publish partial sessions"
```
---
### Task 4: RED — тест atomicity при сбое отправки batch-сообщения
**Objective:** В `CreateSessionHandler` `batch_message_id` обновляется ПОСЛЕ `transaction.Commit()`. Если отправка сообщения в Telegram падает, сессии созданы, но `batch_message_id` не записан — игроки не увидят карточку. Нужно либо доказать, что это обработано, либо исправить.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: Анализ кода**
Текущий порядок в `CreateSessionHandler`:
1. Валидация (до транзакции) ✓
2. `BEGIN TRANSACTION`
3. INSERT players, groups, sessions
4. `COMMIT`
5. `SessionBatchRenderer.Render`
6. `botClient.SendMessage/SendPhoto`
7. `UPDATE sessions SET batch_message_id = ...` (вне транзакции!)
Если шаг 6 падает — сессии «висят» без published message. Это частичное создание.
**Step 2: Write minimal fix**
Обернуть отправку сообщения и обновление `batch_message_id` в retry-loop с fallback. Если после N попыток не удалось — отправить GM уведомление об ошибке и оставить сессии (не удалять, чтобы не терять данные), но сделать так, чтобы `batch_message_id` обновлялся только при успешной отправке.
```csharp
// Внутри CreateSessionHandler, после Commit:
Message? batchMessage = null;
var sendAttempts = 0;
const int maxAttempts = 3;
Exception? lastSendException = null;
while (batchMessage is null && sendAttempts < maxAttempts)
{
sendAttempts++;
try
{
batchMessage = await SendBatchMessageAsync(...); // extracted method
}
catch (Exception ex)
{
lastSendException = ex;
logger.LogWarning(ex, "Attempt {Attempt} failed to send batch message for {BatchId}", sendAttempts, batchId);
if (sendAttempts < maxAttempts)
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
if (batchMessage is not null)
{
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
}
else
{
logger.LogError(lastSendException, "Failed to send batch message for {BatchId} after {MaxAttempts} attempts", batchId, maxAttempts);
await botClient.SendMessage(
chatId,
$"⚠️ Сессии созданы, но не удалось опубликовать карточку. Пожалуйста, используйте /listsessions.\n\nОшибка: {lastSendException?.Message}",
cancellationToken: cancellationToken);
}
```
**Step 3: Extract helper**
Выделить метод `SendBatchMessageAsync` из текущего inline-кода отправки (строки 117–176 в текущем файле), чтобы логика была читаемой и тестируемой.
**Step 4: Run tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/ -v n`
Expected: все существующие тесты PASS, новая логика не ломает smoke-тест (т.к. smoke-тест не использует реальный `CreateSessionHandler`).
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "fix(#19): retry batch message send and prevent orphaned sessions without batch_message_id"
```
---
### Task 5: RED — smoke-тест полного landing-сценария с явными датами
**Objective:** Дополнить `TelegramLandingPromisesSmokeTests` полным сквозным сценарием: парсинг → публикация → запись → выход → проверка карточки.
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs`
**Step 1: Write failing test**
```csharp
[Fact]
public void Smoke_LandingExplicitDatesBatch_ShouldSupportFullLifecycle()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Landing Explicit Batch
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 3
Ссылка: https://example.test/explicit
""";
var parseResult = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(parseResult.IsValid);
Assert.Equal(2, parseResult.ScheduledTimes.Count);
Assert.Equal(3, parseResult.MaxPlayers);
var scenario = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
Assert.Contains("Landing Explicit Batch", scenario.LastMessage.Text);
Assert.Contains("15 мая 2026, 19:30", scenario.LastMessage.Text);
Assert.Contains("22 мая 2026, 19:30", scenario.LastMessage.Text);
Assert.Contains("Места: 0/3", scenario.LastMessage.Text);
var callbacks = CallbackData(scenario.LastMessage.Markup);
Assert.Equal(4, callbacks.Count); // join+leave для каждой из 2 сессий
var firstSessionId = scenario.Sessions[0].Id;
var alice = scenario.Join(firstSessionId, 1001, "Alice", "alice");
var bob = scenario.Join(firstSessionId, 1002, "Bob", "bob");
var carol = scenario.Join(firstSessionId, 1003, "Carol", "carol");
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob));
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol));
Assert.Contains("Места: 2/3", scenario.LastMessage.Text);
Assert.Contains("@alice", scenario.LastMessage.Text);
Assert.Contains("@bob", scenario.LastMessage.Text);
Assert.Contains("@carol", scenario.LastMessage.Text);
scenario.Leave(firstSessionId, alice);
Assert.False(scenario.HasParticipant(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol));
Assert.DoesNotContain("@alice", scenario.LastMessage.Text);
Assert.Contains("@carol", scenario.LastMessage.Text);
}
```
**Step 2: Run test to verify failure**
Run: `dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Smoke_LandingExplicitDatesBatch_ShouldSupportFullLifecycle" -v n`
Expected: PASS (функциональность уже существует, тест добавляет регрессионное покрытие).
**Step 3: Commit**
```bash
git add tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs
git commit -m "test(#19): full lifecycle smoke test for explicit-dates landing batch"
```
---
### Task 6: Проверка и обновление `/help` текста
**Objective:** Убедиться, что текст `/help` точно отражает landing-формат и упоминает batch-сценарий.
**Files:**
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
**Step 1: Read current `/help` text**
Текущий `/help` уже содержит:
```
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
```
**Step 2: Verify alignment**
- Формат совпадает с лендингом ✓
- Упоминается `Мест:`
- Упоминается несколько `Время:`
- Упоминается `Ссылка:`
Никаких изменений не требуется. Если тестировщик считает, что help недостаточно явно описывает batch-сценарий — добавить заголовок:
```
<b>Создать набор сессий (batch):</b>
```
**Step 3: Commit (если изменения были)**
```bash
git add src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs
git commit -m "docs(#19): clarify batch scenario in /help text"
```
---
### Task 7: Финальный прогон и cleanup
**Objective:** Убедиться, что все тесты проходят, нет warnings, и план соответствует acceptance criteria.
**Files:**
- Все изменённые файлы
**Step 1: Run full test suite**
```bash
dotnet test tests/GmRelay.Bot.Tests/ -v n
```
Expected: все тесты PASS.
**Step 2: Verify checklist**
- [ ] `Parse_ShouldHandleLandingBatchWithMultipleExplicitDates` — PASS
- [ ] `Render_ShouldProduceLandingBatchCardWithAllRequiredElements` — PASS
- [ ] `Smoke_InvalidNewSession_ShouldNotPublishAnySessions` — PASS
- [ ] `Smoke_LandingExplicitDatesBatch_ShouldSupportFullLifecycle` — PASS
- [ ] Все существующие тесты — PASS
- [ ] `CreateSessionHandler` обрабатывает сбой отправки batch-сообщения (retry + fallback)
- [ ] `/help` текст соответствует landing-формату
- [ ] Никаких новых warnings при сборке
**Step 3: Commit финальный**
```bash
git add -A
git commit -m "feat(#19): align /newsession with landing batch scenario — tests + atomicity fix"
```
---
## Acceptance Criteria Verification
| Критерий | Статус | Покрытие |
|---|---|---|
| Мастер может создать batch из нескольких дат | ✅ Реализовано | Task 1, Task 5 |
| Карточка содержит название, даты, лимит, заполненность, действия | ✅ Реализовано | Task 2, Task 5 |
| Ошибки ввода не создают частичных сессий | ✅ Реализовано (валидация до транзакции) | Task 3 |
| Сбой публикации карточки не оставляет «висячие» сессии без `batch_message_id` | 🔄 Фиксится | Task 4 |
## Execution Handoff
Plan complete and saved to `.hermes/plans/gmrelay-issue-19.md`. Ready to execute using subagent-driven-development — dispatch a fresh subagent per task with two-stage review (spec compliance then code quality). Shall I proceed?
+752
View File
@@ -0,0 +1,752 @@
# Issue #23: Добавить platform identity и platform_messages в БД
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Расширить PostgreSQL-схему нейтральными platform-полями и таблицей platform_messages, чтобы Discord-адаптер мог работать без чтения `telegram_*` legacy-полей. Сохранить обратную совместимость с Telegram-сценариями.
**Architecture:**
- Добавляем generic `platform` enum-like поля (`VARCHAR(50)`) + `external_*` ID-поля рядом с legacy `telegram_*`.
- Создаём таблицу `platform_messages` для отслеживания batch-сообщений на любой платформе.
- Миграция обратима: `telegram_*` НЕ удаляются, новые поля nullable/с дефолтами.
- После миграции обновляем SQL-запросы в handlers (Task 8-12) чтобы сначала писать и читать через новые поля.
**Tech Stack:** PostgreSQL 16+, DbUp, Dapper, .NET 10, Npgsql.
**Milestone:** Версия 2.0 — Discord Bot MVP
---
## Task 1: Написать миграцию V011__add_platform_identity.sql
**Objective:** Создать обратимую миграцию, добавляющую platform-поля и таблицу platform_messages.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql`
**Step 1: Write failing test**
```bash
# Проверяем, что миграция ещё не существует
ls src/GmRelay.Bot/Migrations/V011__*.sql
# Expected: FAIL — file not found
```
**Step 2: Write migration script**
```sql
-- =============================================================
-- V011: Add platform identity fields for multi-platform support
-- Telegram remains default platform; old telegram_* fields kept.
-- =============================================================
-- 1) players: add platform identity columns
ALTER TABLE players
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_user_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255);
-- Populate external_user_id/external_username from existing telegram data
UPDATE players
SET external_user_id = telegram_id::VARCHAR(255),
external_username = telegram_username
WHERE external_user_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_players_platform_external_user
ON players (platform, external_user_id);
-- 2) game_groups: add platform identity columns
ALTER TABLE game_groups
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_group_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE game_groups
SET external_group_id = telegram_chat_id::VARCHAR(255)
WHERE external_group_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_game_groups_platform_external_group
ON game_groups (platform, external_group_id);
-- 3) sessions: add external_channel_id for platform-neutral thread/channel tracking
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE sessions
SET external_channel_id = thread_id::VARCHAR(255)
WHERE external_channel_id IS NULL AND thread_id IS NOT NULL;
-- 4) platform_messages: new table for tracking messages across platforms
CREATE TABLE IF NOT EXISTS platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID NOT NULL,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL DEFAULT 'schedule'
CHECK (purpose IN ('schedule','reminder','confirmation','reschedule_vote','direct_notification')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_platform_messages_group_batch
ON platform_messages (group_id, batch_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_session
ON platform_messages (session_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_purpose
ON platform_messages (purpose);
-- 5) Backfill platform_messages from existing sessions.batch_message_id data
INSERT INTO platform_messages (platform, group_id, batch_id, session_id, external_channel_id, external_message_id, purpose)
SELECT 'telegram',
s.group_id,
s.batch_id,
s.id,
NULL,
s.batch_message_id::VARCHAR(255),
'schedule'
FROM sessions s
WHERE s.batch_message_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM platform_messages pm
WHERE pm.batch_id = s.batch_id AND pm.external_message_id = s.batch_message_id::VARCHAR(255)
);
```
**Step 3: Verify migration exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
# Expected: file exists
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
git commit -m "chore(db): add V011 migration — platform identity and platform_messages"
```
---
## Task 2: Создать shared enum PlatformKind
**Objective:** Ввести типизированное перечисление платформ для использования в коде и БД.
**Files:**
- Create: `src/GmRelay.Shared/Domain/PlatformKind.cs`
- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` (если нужен `[JsonStringEnumConverter]` или Dapper mapping)
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
using GmRelay.Shared.Domain;
public class PlatformKindTests
{
[Fact]
public void Telegram_HasExpectedValue()
{
Assert.Equal("telegram", PlatformKind.Telegram.Value);
}
[Fact]
public void Discord_HasExpectedValue()
{
Assert.Equal("discord", PlatformKind.Discord.Value);
}
[Fact]
public void Equals_SameValue_ReturnsTrue()
{
Assert.Equal(PlatformKind.Telegram, PlatformKind.Telegram);
}
[Fact]
public void Parse_KnownValue_ReturnsInstance()
{
var kind = PlatformKind.Parse("discord");
Assert.Equal(PlatformKind.Discord, kind);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: FAIL — PlatformKind not found
```
**Step 2: Implement PlatformKind**
```csharp
// src/GmRelay.Shared/Domain/PlatformKind.cs
namespace GmRelay.Shared.Domain;
public sealed class PlatformKind : IEquatable<PlatformKind>
{
public static readonly PlatformKind Telegram = new("telegram");
public static readonly PlatformKind Discord = new("discord");
public string Value { get; }
private PlatformKind(string value) => Value = value;
public static PlatformKind Parse(string value)
=> value.ToLowerInvariant() switch
{
"telegram" => Telegram,
"discord" => Discord,
_ => throw new ArgumentException($"Unknown platform: {value}", nameof(value))
};
public bool Equals(PlatformKind? other) => other is not null && Value == other.Value;
public override bool Equals(object? obj) => obj is PlatformKind other && Equals(other);
public override int GetHashCode() => Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
public override string ToString() => Value;
}
```
**Step 3: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Shared/Domain/PlatformKind.cs tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
git commit -m "feat(shared): add PlatformKind value object"
```
---
## Task 3: Создать Dapper TypeHandler для PlatformKind
**Objective:** Обеспечить автоматическую сериализацию/десериализацию PlatformKind ↔ VARCHAR в PostgreSQL.
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs`
- Modify: `src/GmRelay.Bot/Program.cs` или точку регистрации Dapper handlers
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
using Dapper;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Domain;
public class PlatformKindTypeHandlerTests
{
[Fact]
public void PlatformKind_SetValue_ReturnsString()
{
var handler = new PlatformKindTypeHandler();
var param = new Npgsql.NpgsqlParameter();
handler.SetValue(param, PlatformKind.Discord);
Assert.Equal("discord", param.Value);
}
[Fact]
public void PlatformKind_Parse_ReturnsKind()
{
var handler = new PlatformKindTypeHandler();
var result = handler.Parse(typeof(PlatformKind), "telegram");
Assert.Equal(PlatformKind.Telegram, result);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: FAIL — handler not found
```
**Step 2: Implement handler**
```csharp
// src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs
using Dapper;
using GmRelay.Shared.Domain;
using System.Data;
namespace GmRelay.Bot.Infrastructure.Database;
public sealed class PlatformKindTypeHandler : SqlMapper.TypeHandler<PlatformKind>
{
public override void SetValue(IDbDataParameter parameter, PlatformKind value)
{
parameter.Value = value.Value;
}
public override PlatformKind Parse(object value)
{
var str = value as string ?? value.ToString() ?? string.Empty;
return PlatformKind.Parse(str);
}
}
```
**Step 3: Register handler**
В `Program.cs` (или в `DbMigrator` / startup extension) добавить:
```csharp
SqlMapper.AddTypeHandler(new PlatformKindTypeHandler());
```
**Step 4: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: PASS
```
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
git commit -m "feat(db): add Dapper PlatformKindTypeHandler"
```
---
## Task 4: Создать down-migration V011__add_platform_identity.sql (rollback)
**Objective:** Обеспечить обратимость миграции для отката при ошибке.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql`
**Step 1: Write rollback script**
```sql
-- Rollback for V011: remove platform identity fields and platform_messages table
-- NOTE: This drops NEW data. Use only in emergency.
DROP TABLE IF EXISTS platform_messages;
ALTER TABLE sessions DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE game_groups
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_group_id,
DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE players
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_user_id,
DROP COLUMN IF EXISTS external_username;
```
**Step 2: Verify file exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
# Expected: file exists
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
git commit -m "chore(db): add V011 rollback script"
```
---
## Task 5: Прогнать миграцию на integration-БД и проверить структуру
**Objective:** Убедиться, что миграция применяется без ошибок, backfill работает, индексы созданы.
**Files:**
- Test: скрипт в `tests/integration/verify_v011.sql` или ручная проверка через psql
**Step 1: Run migrations**
```bash
cd src/GmRelay.Bot
dotnet run --migrate-only # или через DbMigrator в Program.cs
```
**Step 2: Verify schema**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d players" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d game_groups" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d sessions" | grep external
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages"
```
Expected: все новые поля и таблица присутствуют.
**Step 3: Verify backfill**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM players WHERE external_user_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM game_groups WHERE external_group_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM platform_messages;"
```
Expected: counts > 0 для существующих данных.
**Step 4: Verify rollback works**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages" # should fail (table missing)
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql # re-apply
```
**Step 5: Commit** (если добавили тесты)
```bash
git add tests/integration/verify_v011.sql
git commit -m "test(db): add V011 integration verification"
```
---
## Task 6: Обновить CreateSessionHandler — писать в platform identity при создании
**Objective:** При создании сессии заполнять новые `external_*` поля, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
[Fact]
public async Task HandleAsync_CreatesPlayerWithPlatformIdentity()
{
// Arrange: mock NpgsqlDataSource + ITelegramBotClient
// Act: send /newsession
// Assert: INSERT into players includes external_user_id = telegram_id string
}
```
Run:
```bash
dotnet test --filter "FullyQualifiedName~CreateSessionHandlerTests"
# Expected: FAIL — test or implementation missing
```
**Step 2: Update handler SQL**
В `CreateSessionHandler.cs`, обновить INSERT для players:
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username;
```
И INSERT для game_groups:
```sql
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'telegram', @ChatId::VARCHAR(255))
RETURNING id;
```
**Step 3: Run handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~CreateSession"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on session creation"
```
---
## Task 7: Обновить JoinSessionHandler — писать в platform identity
**Objective:** При join записывать `platform`, `external_user_id`, `external_username`.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
**Step 1: Update SQL**
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username
RETURNING id;
```
**Step 2: Verify no regression**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~JoinSession"
# Expected: PASS or no existing tests (manual smoke)
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on join"
```
---
## Task 8: Обновить ListSessionsHandler — читать через platform identity
**Objective:** Запрос списка сессий должен работать через `external_group_id`, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs`
**Step 1: Update SQL**
Заменить `g.telegram_chat_id = @ChatId` на `g.external_group_id = @ChatId::VARCHAR(255)`.
```sql
SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, ...
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.external_group_id = @ChatId::VARCHAR(255)
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY ...
ORDER BY s.scheduled_at ASC
```
Также обновить проверку прав:
```sql
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.external_user_id = @TelegramUserId::VARCHAR(255)
) AS CanManage
```
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ListSessions"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs
git commit -m "feat(sessions): read group via external_group_id in list handler"
```
---
## Task 9: Обновить CancelSessionHandler, LeaveSessionHandler, PromoteWaitlistedPlayerHandler
**Objective:** Аналогично Task 7-8: обновить SQL запросы на использование `external_user_id` и `external_group_id`.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
**Step 1: Update SQL in each handler**
Replace patterns:
- `p.telegram_id = @TelegramUserId``p.external_user_id = @TelegramUserId::VARCHAR(255)`
- `g.telegram_chat_id = @ChatId``g.external_group_id = @ChatId::VARCHAR(255)`
- `s.group_id = g.id` stays
**Step 2: Run all session handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Session"
# Expected: all PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/
git commit -m "feat(sessions): migrate session handlers to platform identity fields"
```
---
## Task 10: Обновить Reschedule и Reminder handlers
**Objective:** Обновить reschedule handlers и reminder sender на platform identity.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
- `src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs`
- `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
**Step 1: Update SQL**
Replace `telegram_id` / `telegram_chat_id` usages with `external_user_id` / `external_group_id` where это lookup.
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule"
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reminder"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ src/GmRelay.Bot/Features/Reminders/ src/GmRelay.Bot/Features/Notifications/
git commit -m "feat(reschedule,reminders): migrate to platform identity fields"
```
---
## Task 11: Обновить Web Dashboard queries (TelegramAuthService, GroupDetails, MiniApp)
**Objective:** Веб-компоненты, которые читают `telegram_id`, должны также поддерживать `external_user_id`.
**Files:**
- Modify:
- `src/GmRelay.Web/Services/TelegramAuthService.cs`
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1: Update TelegramAuthService**
```sql
SELECT p.id, p.display_name, p.telegram_username, p.external_user_id, p.external_username
FROM players p
WHERE p.telegram_id = @TelegramId
OR p.external_user_id = @TelegramId::VARCHAR(255)
```
**Step 2: Update GroupDetails.razor queries**
Replace `telegram_chat_id` lookups with `external_group_id`.
**Step 3: Run web tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Web"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/
git commit -m "feat(web): migrate web dashboard to platform identity fields"
```
---
## Task 12: Создать документацию по platform identity в schema notes
**Objective:** Зафиксировать, что `telegram_*` deprecated, а новые поля — путь вперёд.
**Files:**
- Create: `docs/db-schema/platform-migration-notes.md`
**Step 1: Write doc**
```markdown
# Platform Identity Migration Notes
## Status
- `telegram_*` fields: DEPRECATED but kept for backward compatibility.
- `platform`, `external_*` fields: ACTIVE, used by all new handlers.
## Tables
| Table | Legacy | New Fields |
|-------|--------|-----------|
| players | telegram_id, telegram_username | platform, external_user_id, external_username |
| game_groups | telegram_chat_id | platform, external_group_id, external_channel_id |
| sessions | thread_id, batch_message_id | external_channel_id |
| platform_messages | — | NEW table tracking all batch messages |
## Backfill
V011 migration backfills `external_*` from existing `telegram_*` data.
## Future
V012+ (or separate migration) will eventually drop `telegram_*` once all code paths use `external_*`.
```
**Step 2: Commit**
```bash
git add docs/db-schema/platform-migration-notes.md
git commit -m "docs(db): add platform identity migration notes"
```
---
## Task 13: Final integration smoke test
**Objective:** Убедиться, что весь бот стартует, миграции проходят, и основные команды работают.
**Step 1: Build**
```bash
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
# Expected: 0 errors, 0 warnings
```
**Step 2: Run all tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --no-build
# Expected: all PASS
```
**Step 3: Start bot and test /newsession + /listsessions**
```bash
dotnet run --project src/GmRelay.Bot/GmRelay.Bot.csproj
# Manual smoke in Telegram: /newsession → verify session created, players.external_user_id populated
```
**Step 4: Commit final state**
```bash
git status # should be clean
git log --oneline -5
```
---
## Verification Checklist
- [ ] V011 migration applied successfully with backfill
- [ ] Rollback script tested and works
- [ ] PlatformKind + Dapper TypeHandler present and tested
- [ ] All session handlers use `external_*` fields
- [ ] Telegram commands (`/newsession`, `/listsessions`, join/leave) still work
- [ ] Web dashboard auth and group pages still work
- [ ] All tests pass
- [ ] No compiler warnings
+4
View File
@@ -0,0 +1,4 @@
usage: hermes mcp [-h] [--accept-hooks]
{serve,add,remove,rm,list,ls,test,configure,config,login}
...
hermes mcp: error: argument mcp_action: invalid choice: 'call' (choose from 'serve', 'add', 'remove', 'rm', 'list', 'ls', 'test', 'configure', 'config', 'login')
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.10.5</Version>
<Version>1.10.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+171 -78
View File
@@ -4,132 +4,225 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.10.2`.
**Текущая версия:** `v1.9.9`.
---
## ✨ Key Features
## ✨ Ключевые возможности
### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **🖼 Обложки расписаний**: К batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
- перенести пачку на фиксированный шаг в днях;
- клонировать batch на следующую неделю или месяц.
- **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди.
- **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат.
- **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают Telegram-сообщения расписания.
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard. Если Mini App попадает в fallback-вход, Telegram Login Widget авторизует пользователя callback-запросом внутри текущего WebView, а интерфейс учитывает safe-area телефона и верхнюю панель Telegram.
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
---
## 🛠 Технологический стек
| Компонент | Технология |
|---|---|
| Язык | C# 14 (.NET 10) |
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
| Бот | Telegram.Bot, **Native AOT** |
| Веб | Blazor Server |
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
| БД | PostgreSQL |
| ORM | Dapper + **Dapper.AOT** (source generators) |
| Миграции | DbUp |
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
> [!NOTE]
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
- **Язык**: C# 14 (.NET 10)
- **Архитектура**: Vertical Slice Architecture, общая библиотека (`GmRelay.Shared`) для доменной логики.
- **Бот**: Telegram.Bot, Native AOT.
- **Веб-интерфейс**: Blazor Server.
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`).
- **База данных**: PostgreSQL
- **ORM**: Dapper (с использованием Dapper.AOT для source generators).
- **Миграции**: DbUp.
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64).
---
## 🚀 Быстрый старт (Docker Compose)
**Требования:** Docker и Docker Compose.
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса.
### 1. Подготовка
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
### 2. Настройка окружения
Скопируйте файл-шаблон и заполните его значениями:
### 1. Настройка окружения
```bash
cp .env.example .env
```
**Ключевые переменные `.env`:**
Отредактируйте `.env`:
```env
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации)
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Имя бота без @ (для Telegram Login Widget)
# Имя вашего бота в Telegram (без @), например: GmRelayBot.
# Найти его можно в информации о боте у @BotFather.
# Используется для работы виджета авторизации (Telegram Login Widget).
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# HTTPS URL Mini App, например https://your-domain.example/miniapp
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp.
# Используется кнопкой меню Telegram и кнопкой /start.
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
```
**Настройка в @BotFather:**
- Команда `/setdomain` для работы виджета авторизации на вашем домене.
- Для Mini App настройте домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`.
- Начиная с **v1.9.3** дополнительных действий для фикса входа не требуется: fallback выполняется внутри активного Telegram WebView по тому же HTTPS-адресу `/miniapp`.
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
### 2. Запуск
Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана. Начиная с v1.9.3 дополнительных действий в BotFather для фикса входа не требуется: URL остаётся тем же HTTPS-адресом `/miniapp`, а fallback-вход выполняется внутри активного Telegram WebView.
### 3. Запуск
Выполните команду:
```bash
docker compose up -d
```
**Автоматически выполняется:**
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск веб-приложения с подключением к БД и Telegram API.
### 3. Первоначальная настройка
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
Инфраструктура автоматически:
- Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
- Запустит бота (применив миграции БД).
- Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
---
## 🗂 Структура репозитория
## ⚙️ Настройка бота в Telegram
Чтобы бот работал корректно:
1. **Добавьте бота в группу** (или Супергруппу/Форум).
2. **Назначьте бота Администратором**.
3. **Необходимые права**:
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
* `Отправка сообщений`.
* `Закрепление сообщений` — рекомендуется.
> [!TIP]
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
---
## 📝 Инструкция для Мастера
### Создание расписания игр
Используйте команду `/newsession` с описанием в следующем формате:
```text
/newsession
Название: Легенды Берега Мечей (D&D 5e)
Время: 15.05.2024 19:30
Время: 22.05.2024 19:00
Мест: 4
Ссылка: https://discord.gg/invite-link
Картинка: https://example.com/adventure-cover.jpg
```
├── src/
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
│ ├── GmRelay.Bot/ # Telegram-бот (Native AOT)
│ ├── GmRelay.Migrator/ # DbUp-миграции
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
│ ├── GmRelay.Web/ # Blazor Server dashboard
│ └── GmRelay.Worker/ # Background workers
├── tests/
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
├── compose.yaml # Docker Compose (AMD64 + ARM64)
└── .env.example # Шаблон переменных окружения
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Строка `Картинка:` тоже необязательна. Вместо ссылки можно прикрепить фото к сообщению `/newsession` и поместить команду в подпись к фото; бот возьмёт прикреплённое изображение и отправит его перед расписанием.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 6
Интервал: 7
Мест: 5
Ссылка: https://discord.gg/invite-link
```
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:``Шаг:`.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
### Перенос сессии голосованием
Owner или co-GM вызывает `/listsessions`, нажимает кнопку `⏰` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном:
```text
25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00
```
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
### Шаблоны и bulk-операции в Web Dashboard
Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны.
На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут:
- создать новый batch из шаблона, выбрав только первую дату расписания;
- обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Telegram Mini App Dashboard
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram: через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Дополнительный пароль вводить не нужно: GM-Relay сам проверит вход через Telegram.
Внутри открывается та же панель управления, только удобная для телефона. Можно смотреть свои группы, редактировать игры, управлять листом ожидания, запускать шаблоны и выполнять массовые действия с пачками игр. Чужие группы не появятся: dashboard показывает только те группы, где вы owner или co-GM.
Если автоматический вход не сработал, Mini App покажет понятное сообщение и кнопку входа через Telegram. Нажмите её, подтвердите вход, и dashboard откроется в том же окне Telegram.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе. Для owner/co-GM команда также показывает кнопки отмены, переноса, повышения из листа ожидания и удаления.
- `⏰` в панели `/listsessions` — Запустить голосование по 2-3 вариантам нового времени.
- `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату.
---
## 🏗 Разработка и запуск локально (.NET Aspire)
Для локальной разработки проще всего использовать .NET Aspire:
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) и workload Aspire.
2. Откройте решение `GM-Relay.slnx`.
3. Установите переменные окружения (или user secrets) для `GmRelay.AppHost`.
4. Запустите проект `GmRelay.AppHost`. Aspire Dashboard запустится автоматически, предоставляя удобный мониторинг БД, бота и веб-интерфейса.
> [!NOTE]
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы используют строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются.
---
## 🧪 Тестирование
Основной набор проверок запускается командой:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"
```
Начиная с `v1.9.9`, тестовый набор включает функциональный smoke-сценарий обещаний лендинга для Telegram: batch-сессии на несколько дат, inline-кнопки записи/выхода, лимиты мест, waitlist, автоповышение игрока, голосование за перенос, direct-notification mode и перерисовку Telegram batch-поста после dashboard-изменений. Smoke работает через fake Telegram messenger и не требует внешнего Telegram API.
---
## 📜 Лицензия
MIT License. См. [LICENSE](./LICENSE).
---
*Построено с ❤️ для TTRPG-сообщества.*
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.5
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0
restart: always
depends_on:
db:
@@ -30,7 +30,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.5
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0
restart: always
depends_on:
db:
@@ -1,65 +0,0 @@
# ADR 002: Platform-Neutral Batch Rendering
## Status
**Accepted** — implemented in v1.10.0 (PR #42).
## Context
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
## Decision
Разделить рендеринг на две стадии:
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
```
Domain DTOs
SessionBatchViewBuilder (Shared)
SessionBatchViewModel (platform-neutral)
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
└──► DiscordSessionBatchRenderer ──► (issue #26)
```
### Изменённые компоненты
| Компонент | Было | Стало |
|---|---|---|
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.Shared.Rendering` (stub) |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Можно добавить `DiscordSessionBatchRenderer` без изменений в `Shared`.
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
### Negative
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
## Related
- Issue #22 — этот рефакторинг.
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
@@ -1,11 +1,9 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -13,21 +11,20 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
IConfiguration configuration)
ITelegramBotClient botClient)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.telegram_chat_id = @ChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
@@ -57,6 +54,8 @@ public sealed class ExportCalendarHandler(
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
sb.AppendLine("END:VEVENT");
}
@@ -67,45 +66,11 @@ public sealed class ExportCalendarHandler(
var inputFile = InputFile.FromStream(stream, "schedule.ics");
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
new { ChatId = message.Chat.Id });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var replyMarkup = subscriptionUrl is not null
? new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithUrl("🔗 Подписаться на календарь", subscriptionUrl) }
})
: null;
await botClient.SendDocument(
chatId: message.Chat.Id,
document: inputFile,
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: replyMarkup,
messageThreadId: message.MessageThreadId,
cancellationToken: cancellationToken);
}
@@ -33,7 +33,7 @@ public static class BatchMessageEditor
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (global::Telegram.Bot.Exceptions.ApiRequestException ex)
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
@@ -1,5 +1,3 @@
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
@@ -1,11 +0,0 @@
CREATE TABLE calendar_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL,
user_telegram_id BIGINT NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
filter_type SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id);
@@ -1,66 +0,0 @@
-- =============================================================
-- Attendance statistics view for GM analytics
-- Returns per-player aggregated metrics for a given game group.
-- NOTE: waitlist count reflects CURRENT registration_status only.
-- Full historical waitlist tracking will come with #15.
-- =============================================================
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
p.telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -1,16 +0,0 @@
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);
-3
View File
@@ -9,8 +9,5 @@
"Telegram": {
"BotToken": "",
"MiniAppUrl": ""
},
"Web": {
"BaseUrl": ""
}
}
@@ -1,7 +0,0 @@
namespace GmRelay.Shared.Domain;
public enum CalendarSubscriptionFilter
{
AllMyGroups = 0,
SpecificGroup = 1
}
@@ -57,4 +57,3 @@ public static class SessionBatchViewBuilder
return $"✋ На {dateTitle}";
}
}
// trigger pr
@@ -2,10 +2,10 @@
<div class="nav-header">
<a class="nav-brand" href="">
<img src="logo.png" alt="GM-Relay" class="nav-brand-icon" />
<span class="nav-brand-icon">🎲</span>
<span class="nav-brand-text">GM-Relay</span>
</a>
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Переключить меню">
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Навигационное меню">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
@@ -23,7 +23,7 @@
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная страница
Панель управления
</NavLink>
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -52,11 +52,11 @@
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Выход
Выйти
</button>
</form>
<div class="nav-version">v1.10.5</div>
<div class="nav-version">v1.9.9</div>
</div>
</Authorized>
<NotAuthorized>
@@ -67,7 +67,7 @@
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
Вход
Войти
</NavLink>
</div>
</NotAuthorized>
@@ -79,4 +79,4 @@
private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false;
}
}
@@ -9,17 +9,14 @@
.nav-brand {
display: flex;
align-items: baseline;
gap: 0.75rem;
align-items: center;
gap: 0.625rem;
text-decoration: none;
color: var(--text-primary);
}
.nav-brand-icon {
width: 1.5rem;
height: 1.5rem;
object-fit: contain;
display: block;
font-size: 1.5rem;
}
.nav-brand-text {
@@ -187,14 +184,6 @@
.nav-body.open {
display: flex;
position: fixed;
inset: 0;
z-index: 200;
background: linear-gradient(180deg, #0f1629 0%, #1a0a2e 100%);
padding-top: 4.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 1rem;
}
.nav-header {
@@ -32,7 +32,6 @@
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
<a href="/groupstats/@GroupId" class="btn-gm btn-gm-outline">📊 Статистика</a>
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@@ -252,7 +251,6 @@
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline">📜 История</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -340,7 +338,6 @@
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">📜 История</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -1,235 +0,0 @@
@page "/group/{GroupId:guid}/stats"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Статистика — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="/group/@GroupId">Сессии группы</a></li>
<li class="active">Статистика</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📊 Статистика посещаемости</h2>
<p class="page-subtitle">Надёжность состава и качество расписания</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (stats is null)
{
<div class="loading-spinner">⏳ Загружаем статистику…</div>
}
else if (stats.Count == 0)
{
<div class="empty-state">
<div class="empty-icon">📈</div>
<h3>Пока нет данных</h3>
<p>После первых сессий здесь появится аналитика.</p>
</div>
}
else
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="stats-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-value">@stats.Count</div>
<div class="stat-label">Игроков</div>
</div>
<div class="stat-card">
<div class="stat-value">@TotalSessions</div>
<div class="stat-label">Сессий</div>
</div>
<div class="stat-card">
<div class="stat-value">@AvgAttendanceRate%</div>
<div class="stat-label">Средняя посещаемость</div>
</div>
<div class="stat-card">
<div class="stat-value">@topPlayer?.DisplayName</div>
<div class="stat-label">Самый стабильный</div>
</div>
</div>
<div class="table-responsive">
<table class="gm-table" style="width: 100%;">
<thead>
<tr>
<th @onclick="@(() => SortBy("player"))" style="cursor:pointer;" class="sortable">Игрок @(sortColumn == "player" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("total"))" style="cursor:pointer; text-align:center;" class="sortable">Всего @(sortColumn == "total" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("confirmed"))" style="cursor:pointer; text-align:center;" class="sortable">✅ @(sortColumn == "confirmed" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("declined"))" style="cursor:pointer; text-align:center;" class="sortable">❌ @(sortColumn == "declined" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("noresponse"))" style="cursor:pointer; text-align:center;" class="sortable">💤 @(sortColumn == "noresponse" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("waitlist"))" style="cursor:pointer; text-align:center;" class="sortable">⏳ @(sortColumn == "waitlist" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("rate"))" style="cursor:pointer; text-align:center;" class="sortable">% @(sortColumn == "rate" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("cancelled"))" style="cursor:pointer; text-align:center;" class="sortable">🚫 @(sortColumn == "cancelled" ? (sortDesc ? "▼" : "▲") : "")</th>
</tr>
</thead>
<tbody>
@foreach (var s in sortedStats)
{
<tr>
<td>
<div class="player-info">
<span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername))
{
<span class="player-username">@@@s.TelegramUsername</span>
}
</div>
</td>
<td style="text-align:center;">@s.TotalSessions</td>
<td style="text-align:center;">@s.ConfirmedCount</td>
<td style="text-align:center;">@s.DeclinedCount</td>
<td style="text-align:center;">@s.NoResponseCount</td>
<td style="text-align:center;">@s.WaitlistedCount</td>
<td style="text-align:center;">
<span class="rate-badge @AttendanceBadgeClass(s.AttendanceRate)">
@s.AttendanceRate%
</span>
</td>
<td style="text-align:center;">@s.CancellationAffectedCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<style>
.stat-card {
background: var(--card-bg-secondary, rgba(255,255,255,0.05));
border-radius: 0.75rem;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent-color, #7cb97a);
}
.stat-label {
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
margin-top: 0.25rem;
}
.player-info {
display: flex;
flex-direction: column;
}
.player-name {
font-weight: 500;
}
.player-username {
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
}
.rate-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
}
.rate-excellent { background: rgba(34,197,94,0.15); color: #22c55e; }
.rate-good { background: rgba(234,179,8,0.15); color: #eab308; }
.rate-poor { background: rgba(239,68,68,0.15); color: #ef4444; }
</style>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<PlayerAttendanceStats>? stats;
private List<PlayerAttendanceStats> sortedStats = new();
private string? errorMessage;
private string sortColumn = "confirmed";
private bool sortDesc = true;
private int TotalSessions => stats?.Count > 0 ? (int)(stats.Max(s => s.TotalSessions)) : 0;
private int AvgAttendanceRate => stats?.Count > 0 ? (int)(stats.Average(s => s.AttendanceRate)) : 0;
private PlayerAttendanceStats? topPlayer => stats?.OrderByDescending(s => s.AttendanceRate).ThenByDescending(s => s.ConfirmedCount).FirstOrDefault();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (!user.Identity?.IsAuthenticated ?? true)
{
Navigation.NavigateTo("/login");
return;
}
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!long.TryParse(telegramIdClaim, out var telegramId))
{
Navigation.NavigateTo("/login");
return;
}
try
{
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
UpdateSortedStats();
}
catch (Exception ex)
{
errorMessage = $"Ошибка загрузки статистики: {ex.Message}";
}
}
private void SortBy(string column)
{
if (sortColumn == column)
sortDesc = !sortDesc;
else
{
sortColumn = column;
sortDesc = true;
}
UpdateSortedStats();
}
private void UpdateSortedStats()
{
if (stats is null) { sortedStats = new(); return; }
IOrderedEnumerable<PlayerAttendanceStats> ordered = sortColumn switch
{
"player" => sortDesc ? stats.OrderByDescending(s => s.DisplayName) : stats.OrderBy(s => s.DisplayName),
"total" => sortDesc ? stats.OrderByDescending(s => s.TotalSessions) : stats.OrderBy(s => s.TotalSessions),
"confirmed" => sortDesc ? stats.OrderByDescending(s => s.ConfirmedCount) : stats.OrderBy(s => s.ConfirmedCount),
"declined" => sortDesc ? stats.OrderByDescending(s => s.DeclinedCount) : stats.OrderBy(s => s.DeclinedCount),
"noresponse" => sortDesc ? stats.OrderByDescending(s => s.NoResponseCount) : stats.OrderBy(s => s.NoResponseCount),
"waitlist" => sortDesc ? stats.OrderByDescending(s => s.WaitlistedCount) : stats.OrderBy(s => s.WaitlistedCount),
"rate" => sortDesc ? stats.OrderByDescending(s => s.AttendanceRate) : stats.OrderBy(s => s.AttendanceRate),
"cancelled" => sortDesc ? stats.OrderByDescending(s => s.CancellationAffectedCount) : stats.OrderBy(s => s.CancellationAffectedCount),
_ => stats.OrderByDescending(s => s.ConfirmedCount)
};
sortedStats = ordered.ToList();
}
private string SortIndicator(string column) => sortColumn == column ? (sortDesc ? "▼" : "▲") : "";
private string AttendanceBadgeClass(decimal rate) => rate switch
{
>= 75m => "rate-excellent",
>= 50m => "rate-good",
_ => "rate-poor"
};
}
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="login-page">
<div class="login-card">
<img src="logo.png" alt="GM-Relay" class="login-logo" />
<div class="login-logo">🎲</div>
<h1 class="login-title">GM-Relay</h1>
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
@@ -8,7 +8,7 @@
<div class="mini-app-page">
<div class="mini-app-auth-card" data-auth-status="@miniAppAuthStatus">
<img src="logo.png" alt="GM-Relay" class="mini-app-logo" />
<div class="mini-app-logo">🎲</div>
<h1>GM-Relay</h1>
<p>@statusMessage</p>
@@ -1,124 +0,0 @@
@page "/session/{SessionId:guid}/history"
@using GmRelay.Web.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>История изменений — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="/group/@groupId">Группа</a></li>
<li class="active">История изменений</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📜 История изменений</h2>
@if (sessionTitle is not null)
{
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
}
</div>
@if (entries is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
</div>
}
else if (entries.Count == 0)
{
<div class="glass-card animate-slide-up" style="padding: 2rem; text-align: center;">
<p style="color: var(--text-muted);">История изменений пуста. Значимые изменения (время, ссылка, название, участники) будут отображаться здесь.</p>
</div>
}
else
{
<div class="glass-card animate-slide-up">
<div class="table-responsive">
<table class="gm-table">
<thead>
<tr>
<th>Время</th>
<th>Актор</th>
<th>Тип изменения</th>
<th>Было</th>
<th>Стало</th>
</tr>
</thead>
<tbody>
@foreach (var entry in entries)
{
<tr>
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
<td>@entry.ActorName (@entry.ActorTelegramId)</td>
<td>
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
@GetChangeTypeLabel(entry.ChangeType)
</span>
</td>
<td style="max-width: 200px; overflow-wrap: break-word; color: var(--text-muted);">@(entry.OldValue ?? "—")</td>
<td style="max-width: 200px; overflow-wrap: break-word;">@(entry.NewValue ?? "—")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
[Parameter] public Guid SessionId { get; set; }
private List<SessionAuditLogEntry>? entries;
private string? sessionTitle;
private Guid? groupId;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessionTitle = session.Title;
groupId = session.GroupId;
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId);
}
private string GetChangeTypeLabel(string changeType) => changeType switch
{
"Title" => "Название",
"Time" => "Время",
"Link" => "Ссылка",
"MaxPlayers" => "Лимит мест",
"Status" => "Статус",
"WaitlistPromote" => "Продвижение из листа ожидания",
"PlayerRemoved" => "Исключение игрока",
"BatchRescheduled" => "Перенос батча",
"Cancelled" => "Отмена",
_ => changeType
};
private string GetBadgeClass(string changeType) => changeType switch
{
"Cancelled" or "PlayerRemoved" => "status-danger",
"WaitlistPromote" => "status-success",
"BatchRescheduled" or "Time" => "status-warning",
_ => "status-info"
};
}
-19
View File
@@ -23,7 +23,6 @@ builder.AddNpgsqlDataSource("gmrelaydb");
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -146,24 +145,6 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
return Results.Redirect("/");
});
// Public calendar subscription endpoint (no auth required)
app.MapGet("/calendar/{token}.ics", async (
string token,
CalendarSubscriptionService service,
CancellationToken ct) =>
{
try
{
var ics = await service.GetIcsAsync(token, ct);
var bytes = System.Text.Encoding.UTF8.GetBytes(ics);
return Results.File(bytes, "text/calendar", "schedule.ics");
}
catch (SubscriptionNotFoundException)
{
return Results.NotFound();
}
});
app.Run();
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
@@ -66,15 +66,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
}
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
if (session.Title != title)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title);
if (session.ScheduledAt != scheduledAt)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
if (session.JoinLink != joinLink)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink);
if (session.MaxPlayers != maxPlayers)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
}
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
@@ -86,7 +77,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
}
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null);
}
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink)
@@ -125,7 +115,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
}
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
}
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
@@ -241,17 +230,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionHistoryAsync(sessionId);
}
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
@@ -261,7 +239,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
@@ -1,93 +0,0 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Web.Services;
public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
{
private const string IcsProdId = "-//GM-Relay//TTRPG Schedule//EN";
public string GenerateToken() => Guid.NewGuid().ToString("N");
public async Task<string> CreateSubscriptionAsync(
long userTelegramId,
Guid? groupId,
CalendarSubscriptionFilter filter,
CancellationToken ct = default)
{
var token = GenerateToken();
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId, groupId, filterType = (int)filter });
return token;
}
public async Task<string> GetIcsAsync(string token, CancellationToken ct = default)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
FROM calendar_subscriptions
WHERE token = @token
AND (expires_at IS NULL OR expires_at > now())",
new { token });
if (subscription is null)
throw new SubscriptionNotFoundException();
var sessions = await connection.QueryAsync<CalendarSessionDto>(
subscription.FilterType == (int)CalendarSubscriptionFilter.SpecificGroup && subscription.GroupId.HasValue
? @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
WHERE s.group_id = @GroupId
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC"
: @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
WHERE s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { subscription.GroupId, Planned = SessionStatus.Planned });
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine($"PRODID:{IcsProdId}");
sb.AppendLine("CALSCALE:GREGORIAN");
sb.AppendLine("METHOD:PUBLISH");
foreach (var s in sessions)
{
var dtStart = FormatIcsDate(s.ScheduledAt);
var dtEnd = FormatIcsDate(s.ScheduledAt.AddHours(4));
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{FormatIcsDate(DateTime.UtcNow)}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{EscapeIcsText(s.Title)}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
return sb.ToString();
}
private static string FormatIcsDate(DateTime dt) => dt.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
private static string EscapeIcsText(string text) => text
.Replace("\\", "\\\\")
.Replace(";", "\\;")
.Replace(",", "\\,")
.Replace("\n", "\\n")
.Replace("\r", "");
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
}
-27
View File
@@ -2,30 +2,6 @@ using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed record PlayerAttendanceStats(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TotalSessions,
long ConfirmedCount,
long DeclinedCount,
long NoResponseCount,
long WaitlistedCount,
long CancellationAffectedCount,
decimal AttendanceRate
);
public sealed record SessionAuditLogEntry(
Guid Id,
Guid SessionId,
long ActorTelegramId,
string ActorName,
string ChangeType,
string? OldValue,
string? NewValue,
DateTime ChangedAt
);
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
@@ -51,7 +27,4 @@ public interface ISessionStore
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
}
@@ -169,65 +169,6 @@ public sealed class SessionService(
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<PlayerAttendanceStats>(
"""
SELECT
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COUNT(DISTINCT s.id) AS TotalSessions,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Pending' THEN s.id END) AS NoResponseCount,
COUNT(DISTINCT CASE WHEN sp.registration_status = 'Waitlisted' THEN s.id END) AS WaitlistedCount,
COUNT(DISTINCT CASE WHEN s.status = 'Cancelled' AND sp.rsvp_status IN ('Confirmed','Declined') THEN s.id END) AS CancellationAffectedCount,
CASE WHEN COUNT(DISTINCT s.id) > 0
THEN ROUND(
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END)
* 100.0 / COUNT(DISTINCT s.id), 2)
ELSE 0
END AS AttendanceRate
FROM players p
JOIN session_participants sp ON sp.player_id = p.id
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = @GroupId
AND s.scheduled_at <= now()
AND sp.is_gm = false
GROUP BY p.id, p.display_name, p.telegram_username
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""",
new { GroupId = groupId })).ToList();
}
public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.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 = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
}
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var entries = await conn.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 = sessionId });
return entries.ToList();
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
@@ -1,6 +0,0 @@
namespace GmRelay.Web.Services;
public sealed class SubscriptionNotFoundException : Exception
{
public SubscriptionNotFoundException() : base("Calendar subscription not found.") { }
}
@@ -1,4 +1,3 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
+2 -12
View File
@@ -877,13 +877,8 @@ select option {
}
.login-logo {
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
font-size: 2.5rem;
margin-bottom: 0.5rem;
display: block;
margin-left: auto;
margin-right: auto;
}
.login-title {
@@ -924,13 +919,8 @@ select option {
}
.mini-app-logo {
width: 2.25rem;
height: 2.25rem;
object-fit: contain;
font-size: 2.25rem;
margin-bottom: 0.75rem;
display: block;
margin-left: auto;
margin-right: auto;
}
.mini-app-auth-card h1 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

@@ -1,9 +1,9 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Tests.Features.Landing;
@@ -311,7 +311,7 @@ public sealed class TelegramLandingPromisesSmokeTests
private void RenderBatch()
{
var view = SessionBatchViewBuilder.Build(
var viewModel = SessionBatchViewBuilder.Build(
Title,
sessions
.Select(session => new SessionBatchDto(
@@ -327,7 +327,8 @@ public sealed class TelegramLandingPromisesSmokeTests
participant.TelegramUsername,
participant.RegistrationStatus))
.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var renderResult = TelegramSessionBatchRenderer.Render(viewModel);
if (Messenger.HasPublishedMessage)
{
@@ -51,8 +51,8 @@ public sealed class SessionBatchViewBuilderTests
Assert.Equal(2, session.ActivePlayerCount);
Assert.Equal(4, session.MaxPlayers);
Assert.True(session.ActivePlayers.Count == 2);
Assert.True(session.WaitlistedPlayers.Count == 1);
Assert.Equal(2, session.ActivePlayers.Count);
Assert.Equal(1, session.WaitlistedPlayers.Count);
}
[Fact]
@@ -66,8 +66,8 @@ public sealed class SessionBatchViewBuilderTests
var actions = result.Sessions[0].AvailableActions;
Assert.Equal(2, actions.Count);
Assert.Equal("join_session", result.Sessions[0].AvailableActions[0].ActionKey);
Assert.Equal("leave_session", result.Sessions[0].AvailableActions[1].ActionKey);
Assert.Equal("join_session", actions[0].ActionKey);
Assert.Equal("leave_session", actions[1].ActionKey);
Assert.Equal(sessionId, actions[0].SessionId);
Assert.Equal(sessionId, actions[1].SessionId);
}
@@ -1,4 +1,3 @@
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
@@ -8,7 +7,7 @@ namespace GmRelay.Bot.Tests.Rendering;
public sealed class TelegramSessionBatchRendererTests
{
[Fact]
public void Render_ShouldProduceCorrectHtmlAndButtons()
public void Render_ShouldProduceSameTextAsOldRenderer()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
@@ -27,24 +26,49 @@ public sealed class TelegramSessionBatchRendererTests
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
// Old renderer output
var (oldText, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
// New pipeline output
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
var (newText, newMarkup) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("Campaign", text);
Assert.Contains("@alice", text);
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
Assert.Contains("Сессия отменена", text);
Assert.Equal(oldText, newText);
}
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
[Fact]
public void Render_ShouldProduceSameButtonsAsOldRenderer()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{firstSessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{firstSessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{secondSessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{secondSessionId}");
Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("cancel") == true);
Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("reschedule") == true);
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var (_, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
var (_, newMarkup) = TelegramSessionBatchRenderer.Render(view);
var oldButtons = oldMarkup.InlineKeyboard.SelectMany(row => row).ToList();
var newButtons = newMarkup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(oldButtons.Count, newButtons.Count);
for (int i = 0; i < oldButtons.Count; i++)
{
Assert.Equal(oldButtons[i].CallbackData, newButtons[i].CallbackData);
Assert.Equal(oldButtons[i].Text, newButtons[i].Text);
}
}
[Fact]
@@ -167,83 +167,6 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(5, store.LastUpdatedMaxPlayers);
}
[Fact]
public async Task UpdateSessionForGmAsync_LogsAudit_WhenTitleChanges()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var originalTime = DateTime.UtcNow;
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4);
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);
}
[Fact]
public async Task UpdateSessionForGmAsync_LogsMultipleAudits_WhenMultipleFieldsChange()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var originalTime = DateTime.UtcNow;
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var newTime = originalTime.AddDays(1);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5);
Assert.Equal(4, store.LogEntries.Count);
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
Assert.Contains(store.LogEntries, e => e.ChangeType == "Time");
Assert.Contains(store.LogEntries, e => e.ChangeType == "Link");
Assert.Contains(store.LogEntries, e => e.ChangeType == "MaxPlayers");
}
[Fact]
public async Task UpdateSessionForGmAsync_DoesNotLogAudit_WhenNothingChanges()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var originalTime = DateTime.UtcNow;
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4);
Assert.Empty(store.LogEntries);
}
[Fact]
public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
{
@@ -736,13 +659,6 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; }
public Guid? LastRemovedPlayerParticipantId { get; private set; }
public List<SessionAuditLogEntry> LogEntries { get; private set; } = new();
public Guid? LastLogSessionId { get; private set; }
public long? LastLogActorTelegramId { get; private set; }
public string? LastLogActorName { get; private set; }
public string? LastLogChangeType { get; private set; }
public string? LastLogOldValue { get; private set; }
public string? LastLogNewValue { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
@@ -970,25 +886,6 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) =>
Task.FromResult(new List<PlayerAttendanceStats>());
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = actorTelegramId;
LastLogActorName = actorName;
LastLogChangeType = changeType;
LastLogOldValue = oldValue;
LastLogNewValue = newValue;
return Task.CompletedTask;
}
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);