Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a2d1d2d38 | |||
| 706f20e403 | |||
| 4d3362d93f | |||
| b03929174a | |||
| 7e2747ec73 | |||
| ae6be912e3 | |||
| 116bed16a8 | |||
| 063de7ee3e | |||
| 5c4ec562d0 | |||
| dbd481566c | |||
| 3f4571d3a7 | |||
| 8c1e7991cd | |||
| c1fdba510b | |||
| 435399dcf2 | |||
| ddaa0f4279 | |||
| b205967f1a | |||
| 7457315d6f | |||
| 59f9904d66 | |||
| 3b91a009ea | |||
| a6ae5aac31 | |||
| dc26b4d7e4 | |||
| bc6136d91e | |||
| 2e95841ca8 | |||
| a7c8127f90 | |||
| cad4e5c30e | |||
| 77647e4bb8 | |||
| 17c631aef2 | |||
| 89b5196676 | |||
| ab1d2f1683 | |||
| 1bcd88db32 | |||
| 63e613c061 | |||
| dbf59c544a | |||
| 14b9bf15f2 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.9.9
|
||||
VERSION: 1.10.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -0,0 +1,480 @@
|
||||
# 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?
|
||||
@@ -1,752 +0,0 @@
|
||||
# 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
|
||||
@@ -1,4 +0,0 @@
|
||||
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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.10.0</Version>
|
||||
<Version>1.10.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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,9 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -11,20 +13,21 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
|
||||
|
||||
public sealed class ExportCalendarHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient)
|
||||
ITelegramBotClient botClient,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
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();
|
||||
@@ -54,8 +57,6 @@ 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");
|
||||
}
|
||||
|
||||
@@ -66,11 +67,45 @@ 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 (Telegram.Bot.Exceptions.ApiRequestException ex)
|
||||
catch (global::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,3 +1,5 @@
|
||||
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- =============================================================
|
||||
-- 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;
|
||||
@@ -9,5 +9,8 @@
|
||||
"Telegram": {
|
||||
"BotToken": "",
|
||||
"MiniAppUrl": ""
|
||||
},
|
||||
"Web": {
|
||||
"BaseUrl": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum CalendarSubscriptionFilter
|
||||
{
|
||||
AllMyGroups = 0,
|
||||
SpecificGroup = 1
|
||||
}
|
||||
@@ -57,3 +57,4 @@ public static class SessionBatchViewBuilder
|
||||
return $"✋ На {dateTitle}";
|
||||
}
|
||||
}
|
||||
// trigger pr
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
<div class="nav-header">
|
||||
<a class="nav-brand" href="">
|
||||
<span class="nav-brand-icon">🎲</span>
|
||||
<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.9.9</div>
|
||||
<div class="nav-version">v1.10.1</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>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
@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"
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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 =>
|
||||
@@ -145,6 +146,24 @@ 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)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
@@ -2,6 +2,19 @@ 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 interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||
@@ -27,4 +40,5 @@ 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);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,39 @@ 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 AddGroupCoGmAsync(
|
||||
Guid groupId,
|
||||
long ownerTelegramId,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed class SubscriptionNotFoundException : Exception
|
||||
{
|
||||
public SubscriptionNotFoundException() : base("Calendar subscription not found.") { }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
|
||||
@@ -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 viewModel = SessionBatchViewBuilder.Build(
|
||||
var view = SessionBatchViewBuilder.Build(
|
||||
Title,
|
||||
sessions
|
||||
.Select(session => new SessionBatchDto(
|
||||
@@ -327,8 +327,7 @@ public sealed class TelegramLandingPromisesSmokeTests
|
||||
participant.TelegramUsername,
|
||||
participant.RegistrationStatus))
|
||||
.ToList());
|
||||
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(viewModel);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
if (Messenger.HasPublishedMessage)
|
||||
{
|
||||
|
||||
@@ -51,8 +51,8 @@ public sealed class SessionBatchViewBuilderTests
|
||||
|
||||
Assert.Equal(2, session.ActivePlayerCount);
|
||||
Assert.Equal(4, session.MaxPlayers);
|
||||
Assert.Equal(2, session.ActivePlayers.Count);
|
||||
Assert.Equal(1, session.WaitlistedPlayers.Count);
|
||||
Assert.True(session.ActivePlayers.Count == 2);
|
||||
Assert.True(session.WaitlistedPlayers.Count == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -66,8 +66,8 @@ public sealed class SessionBatchViewBuilderTests
|
||||
var actions = result.Sessions[0].AvailableActions;
|
||||
|
||||
Assert.Equal(2, actions.Count);
|
||||
Assert.Equal("join_session", actions[0].ActionKey);
|
||||
Assert.Equal("leave_session", actions[1].ActionKey);
|
||||
Assert.Equal("join_session", result.Sessions[0].AvailableActions[0].ActionKey);
|
||||
Assert.Equal("leave_session", result.Sessions[0].AvailableActions[1].ActionKey);
|
||||
Assert.Equal(sessionId, actions[0].SessionId);
|
||||
Assert.Equal(sessionId, actions[1].SessionId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
@@ -7,7 +8,7 @@ namespace GmRelay.Bot.Tests.Rendering;
|
||||
public sealed class TelegramSessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldProduceSameTextAsOldRenderer()
|
||||
public void Render_ShouldProduceCorrectHtmlAndButtons()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
@@ -26,49 +27,24 @@ 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 (newText, newMarkup) = TelegramSessionBatchRenderer.Render(view);
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal(oldText, newText);
|
||||
}
|
||||
Assert.Contains("Campaign", text);
|
||||
Assert.Contains("@alice", text);
|
||||
Assert.Contains("Charlie", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Сессия отменена", text);
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldProduceSameButtonsAsOldRenderer()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -886,6 +886,9 @@ public sealed class AuthorizedSessionServiceTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) =>
|
||||
Task.FromResult(new List<PlayerAttendanceStats>());
|
||||
|
||||
private bool IsManager(Guid groupId, long telegramId) =>
|
||||
IsOwner(groupId, telegramId) ||
|
||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||
|
||||
Reference in New Issue
Block a user