Compare commits
39 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 | |||
| 5dee2d87f5 | |||
| b71488097e | |||
| 6e92419cff | |||
| fdb3445bec | |||
| c1f5d96e25 | |||
| c874f7b797 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.9.6
|
VERSION: 1.10.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 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
|
||||||
BIN
Binary file not shown.
@@ -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,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.9.6</Version>
|
<Version>1.10.1</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.9.6`.
|
**Текущая версия:** `v1.9.9`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,5 +212,17 @@ Owner и co-GM могут открыть мобильный dashboard прямо
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
Основной набор проверок запускается командой:
|
||||||
|
|
||||||
|
```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. Использование в некоммерческих целях приветствуется.
|
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.6
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
- gmrelay
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.6
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
# Player List + Kick + Waitlist Promotion Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player.
|
||||||
|
|
||||||
|
**Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal.
|
||||||
|
|
||||||
|
**Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add domain model for WebParticipant
|
||||||
|
|
||||||
|
**Objective:** Create a DTO to represent a session participant in the web layer.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
|
||||||
|
|
||||||
|
**Step 1: Add record**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record WebParticipant(
|
||||||
|
Guid Id,
|
||||||
|
long TelegramId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
string RsvpStatus,
|
||||||
|
string RegistrationStatus,
|
||||||
|
bool IsGm,
|
||||||
|
DateTime? RespondedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Web/Services/SessionService.cs
|
||||||
|
git commit -m "feat: add WebParticipant record"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add GetSessionParticipantsAsync to ISessionStore
|
||||||
|
|
||||||
|
**Objective:** Retrieve all participants for a session with full player info.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
|
||||||
|
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
|
||||||
|
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
|
||||||
|
|
||||||
|
**Step 1: Add to interface**
|
||||||
|
|
||||||
|
In `ISessionStore.cs`, add:
|
||||||
|
```csharp
|
||||||
|
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement in SessionService**
|
||||||
|
|
||||||
|
In `SessionService.cs`, add:
|
||||||
|
```csharp
|
||||||
|
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return (await conn.QueryAsync<WebParticipant>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS Id,
|
||||||
|
p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
sp.rsvp_status AS RsvpStatus,
|
||||||
|
sp.registration_status AS RegistrationStatus,
|
||||||
|
sp.is_gm AS IsGm,
|
||||||
|
sp.responded_at AS RespondedAt
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
ORDER BY sp.is_gm DESC,
|
||||||
|
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
|
||||||
|
sp.created_at
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId })).ToList();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add authorized wrapper**
|
||||||
|
|
||||||
|
In `AuthorizedSessionService.cs`, add:
|
||||||
|
```csharp
|
||||||
|
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
|
||||||
|
{
|
||||||
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sessionStore.GetSessionParticipantsAsync(sessionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Web/Services/ISessionStore.cs
|
||||||
|
|
||||||
|
git add src/GmRelay.Web/Services/SessionService.cs
|
||||||
|
|
||||||
|
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
|
||||||
|
|
||||||
|
git commit -m "feat: add GetSessionParticipantsAsync"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion
|
||||||
|
|
||||||
|
**Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
|
||||||
|
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
|
||||||
|
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
|
||||||
|
|
||||||
|
**Step 1: Add to interface**
|
||||||
|
|
||||||
|
In `ISessionStore.cs`, add:
|
||||||
|
```csharp
|
||||||
|
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement in SessionService**
|
||||||
|
|
||||||
|
In `SessionService.cs`, add:
|
||||||
|
```csharp
|
||||||
|
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var transaction = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
|
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.max_players AS MaxPlayers,
|
||||||
|
0 AS ActivePlayerCount,
|
||||||
|
0 AS WaitlistedPlayerCount,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
|
FOR UPDATE",
|
||||||
|
new { SessionId = sessionId, GroupId = groupId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify participant exists in this session
|
||||||
|
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS Id,
|
||||||
|
p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
sp.rsvp_status AS RsvpStatus,
|
||||||
|
sp.registration_status AS RegistrationStatus,
|
||||||
|
sp.is_gm AS IsGm,
|
||||||
|
sp.responded_at AS RespondedAt
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
|
||||||
|
""",
|
||||||
|
new { ParticipantId = participantId, SessionId = sessionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (participant is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Участник не найден в этой сессии.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
|
||||||
|
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM session_participants WHERE id = @ParticipantId",
|
||||||
|
new { ParticipantId = participantId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
WebPromotedParticipantDto? promoted = null;
|
||||||
|
|
||||||
|
if (wasActive)
|
||||||
|
{
|
||||||
|
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS ParticipantRowId,
|
||||||
|
p.display_name AS DisplayName
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
ORDER BY sp.created_at ASC, sp.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE OF sp
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (promoted is not null)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE session_participants
|
||||||
|
SET registration_status = @Active,
|
||||||
|
rsvp_status = @Pending,
|
||||||
|
responded_at = NULL
|
||||||
|
WHERE id = @ParticipantRowId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
promoted.ParticipantRowId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Pending = RsvpStatus.Pending
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
await bot.SendMessage(
|
||||||
|
session.TelegramChatId,
|
||||||
|
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
|
if (promoted is not null)
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
session.TelegramChatId,
|
||||||
|
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.BatchMessageId.HasValue)
|
||||||
|
{
|
||||||
|
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add authorized wrapper**
|
||||||
|
|
||||||
|
In `AuthorizedSessionService.cs`, add:
|
||||||
|
```csharp
|
||||||
|
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
|
||||||
|
{
|
||||||
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Web/Services/ISessionStore.cs
|
||||||
|
|
||||||
|
git add src/GmRelay.Web/Services/SessionService.cs
|
||||||
|
|
||||||
|
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
|
||||||
|
|
||||||
|
git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Modify GroupDetails.razor to show participant list
|
||||||
|
|
||||||
|
**Objective:** Add expandable player lists to each session row with kick buttons.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
|
||||||
|
|
||||||
|
**Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables.
|
||||||
|
|
||||||
|
**Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods.
|
||||||
|
|
||||||
|
**Step 3:** In desktop table, add a new column or expand row with participant list.
|
||||||
|
|
||||||
|
**Step 4:** In mobile cards, add expandable participant section.
|
||||||
|
|
||||||
|
**Step 5:** Add styles to `app.css` if needed (badge styles are already present).
|
||||||
|
|
||||||
|
**Step 6:** Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Web/Components/Pages/GroupDetails.razor
|
||||||
|
|
||||||
|
git add src/GmRelay.Web/wwwroot/app.css
|
||||||
|
|
||||||
|
git commit -m "feat: show player list and kick button in GroupDetails"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Modify EditSession.razor to show participant list
|
||||||
|
|
||||||
|
**Objective:** Show participant list on the edit page with kick capability.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor`
|
||||||
|
|
||||||
|
**Step 1:** Load participants in `OnInitializedAsync`.
|
||||||
|
|
||||||
|
**Step 2:** Render participant list below the edit form.
|
||||||
|
|
||||||
|
**Step 3:** Add kick button for each non-GM participant.
|
||||||
|
|
||||||
|
**Step 4:** Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Web/Components/Pages/EditSession.razor
|
||||||
|
|
||||||
|
git commit -m "feat: show player list and kick button in EditSession"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Add backend tests
|
||||||
|
|
||||||
|
**Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs`
|
||||||
|
|
||||||
|
**Step 1:** Write tests for `GetSessionParticipantsForGmAsync`.
|
||||||
|
|
||||||
|
**Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion.
|
||||||
|
|
||||||
|
**Step 3:** Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4:** Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs
|
||||||
|
|
||||||
|
git commit -m "test: add SessionParticipant service tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Update README
|
||||||
|
|
||||||
|
**Objective:** Bump version and document new features.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Step 1:** Change version from `v1.9.6` to `v1.9.7`.
|
||||||
|
|
||||||
|
**Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote.
|
||||||
|
|
||||||
|
**Step 3:** Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
|
||||||
|
git commit -m "docs: bump README to v1.9.7, document player list kick"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Update Wiki
|
||||||
|
|
||||||
|
**Objective:** Update `Руководство ГМа` page with player management instructions.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: Wiki page `Руководство ГМа`
|
||||||
|
|
||||||
|
**Step 1:** Read current wiki content via MCP.
|
||||||
|
|
||||||
|
**Step 2:** Add section about viewing player list and removing players.
|
||||||
|
|
||||||
|
**Step 3:** Update via MCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Push branch and run CI
|
||||||
|
|
||||||
|
**Objective:** Push branch, monitor workflow, fix issues.
|
||||||
|
|
||||||
|
**Step 1:** Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/player-list-kick-waitlist
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:** Check workflow run via MCP gitea actions.
|
||||||
|
|
||||||
|
**Step 3:** Fix any issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Merge and create release
|
||||||
|
|
||||||
|
**Objective:** Merge PR (or fast-forward), tag, create release.
|
||||||
|
|
||||||
|
**Step 1:** Merge to main
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:** Tag v1.9.7
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.9.7
|
||||||
|
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3:** Create release via MCP gitea_create_release.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
|
|||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -102,17 +103,18 @@ public sealed class CancelSessionHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: command.ChatId,
|
chatId: command.ChatId,
|
||||||
messageId: session.BatchMessageId ?? command.MessageId,
|
messageId: session.BatchMessageId ?? command.MessageId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
replyMarkup: renderResult.Markup,
|
||||||
replyMarkup: renderResult.Markup,
|
ct);
|
||||||
cancellationToken: ct);
|
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering;
|
|||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -183,33 +184,66 @@ public sealed class CreateSessionHandler(
|
|||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||||
|
|
||||||
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
if (imageReference is not null)
|
Message batchMessage;
|
||||||
|
|
||||||
|
if (imageReference is not null && renderResult.Text.Length <= 1024)
|
||||||
{
|
{
|
||||||
|
// Картинка + расписание умещаются в одном Telegram-фото с подписью
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await botClient.SendPhoto(
|
batchMessage = await botClient.SendPhoto(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageThreadId: messageThreadId,
|
messageThreadId: messageThreadId,
|
||||||
photo: InputFile.FromString(imageReference),
|
photo: InputFile.FromString(imageReference),
|
||||||
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
caption: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
|
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
|
||||||
|
batchMessage = await botClient.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
messageThreadId: messageThreadId,
|
||||||
|
text: renderResult.Text,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Текст слишком длинный для caption — fallback на два сообщения
|
||||||
|
if (imageReference is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendPhoto(
|
||||||
|
chatId: chatId,
|
||||||
|
messageThreadId: messageThreadId,
|
||||||
|
photo: InputFile.FromString(imageReference),
|
||||||
|
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var batchMessage = await botClient.SendMessage(
|
batchMessage = await botClient.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageThreadId: messageThreadId,
|
messageThreadId: messageThreadId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Telegram.Bot;
|
|||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -136,15 +137,16 @@ public sealed class JoinSessionHandler(
|
|||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: command.ChatId,
|
chatId: command.ChatId,
|
||||||
messageId: command.MessageId,
|
messageId: command.MessageId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
|
|
||||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
|||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -182,15 +183,16 @@ public sealed class LeaveSessionHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: command.ChatId,
|
chatId: command.ChatId,
|
||||||
messageId: command.MessageId,
|
messageId: command.MessageId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
|
|
||||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Вы удалены из листа ожидания."
|
? "Вы удалены из листа ожидания."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
|||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -162,15 +163,16 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: command.ChatId,
|
chatId: command.ChatId,
|
||||||
messageId: session.BatchMessageId ?? command.MessageId,
|
messageId: session.BatchMessageId ?? command.MessageId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
@@ -11,20 +13,21 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
|
|||||||
|
|
||||||
public sealed class ExportCalendarHandler(
|
public sealed class ExportCalendarHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient botClient)
|
ITelegramBotClient botClient,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||||
FROM sessions s
|
+ " FROM sessions s"
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||||
WHERE g.telegram_chat_id = @ChatId
|
+ " WHERE g.telegram_chat_id = @ChatId"
|
||||||
AND s.status = @Planned
|
+ " AND s.status = @Planned"
|
||||||
AND s.scheduled_at > NOW()
|
+ " AND s.scheduled_at > NOW()"
|
||||||
ORDER BY s.scheduled_at ASC",
|
+ " ORDER BY s.scheduled_at ASC",
|
||||||
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
var sessionsList = sessions.ToList();
|
||||||
@@ -54,8 +57,6 @@ public sealed class ExportCalendarHandler(
|
|||||||
sb.AppendLine($"DTSTART:{dtStart}");
|
sb.AppendLine($"DTSTART:{dtStart}");
|
||||||
sb.AppendLine($"DTEND:{dtEnd}");
|
sb.AppendLine($"DTEND:{dtEnd}");
|
||||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
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");
|
sb.AppendLine("END:VEVENT");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +67,45 @@ public sealed class ExportCalendarHandler(
|
|||||||
|
|
||||||
var inputFile = InputFile.FromStream(stream, "schedule.ics");
|
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(
|
await botClient.SendDocument(
|
||||||
chatId: message.Chat.Id,
|
chatId: message.Chat.Id,
|
||||||
document: inputFile,
|
document: inputFile,
|
||||||
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
messageThreadId: message.MessageThreadId,
|
messageThreadId: message.MessageThreadId,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -6,6 +6,7 @@ using Npgsql;
|
|||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
@@ -375,16 +376,16 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
if (proposal.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var renderResult = SessionBatchRenderer.Render(
|
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||||
proposal.Title, batchSessions, batchParticipants);
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: proposal.TelegramChatId,
|
chatId: proposal.TelegramChatId,
|
||||||
messageId: proposal.BatchMessageId.Value,
|
messageId: proposal.BatchMessageId.Value,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
+6
-4
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
|
|||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.Enums;
|
using Telegram.Bot.Types.Enums;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
@@ -304,15 +305,16 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
if (proposal.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: proposal.TelegramChatId,
|
chatId: proposal.TelegramChatId,
|
||||||
messageId: proposal.BatchMessageId.Value,
|
messageId: proposal.BatchMessageId.Value,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles editing batch messages that may be either text or photo messages.
|
||||||
|
/// When the batch was created with SendPhoto (image + caption), we need
|
||||||
|
/// EditMessageCaption instead of EditMessageText.
|
||||||
|
/// </summary>
|
||||||
|
public static class BatchMessageEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Edits a batch message, automatically detecting whether it is a text or photo message.
|
||||||
|
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EditBatchMessageAsync(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
long chatId,
|
||||||
|
int messageId,
|
||||||
|
string text,
|
||||||
|
InlineKeyboardMarkup? replyMarkup,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
text: text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
|
||||||
|
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
|
||||||
|
await bot.EditMessageCaption(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
caption: caption,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public static class TelegramSessionBatchRenderer
|
||||||
|
{
|
||||||
|
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
|
||||||
|
{
|
||||||
|
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
|
||||||
|
$"<b>Расписание:</b>\n\n";
|
||||||
|
|
||||||
|
var buttons = new List<InlineKeyboardButton[]>();
|
||||||
|
|
||||||
|
foreach (var session in view.Sessions)
|
||||||
|
{
|
||||||
|
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||||
|
messageText += session.MaxPlayers.HasValue
|
||||||
|
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
|
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||||
|
|
||||||
|
if (session.ActivePlayers.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||||
|
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messageText += " <i>Пока никто не записался</i>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.WaitlistedPlayers.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||||
|
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||||
|
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
|
||||||
|
{
|
||||||
|
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messageText += "\n";
|
||||||
|
var actionRow = session.AvailableActions
|
||||||
|
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
|
||||||
|
.ToArray();
|
||||||
|
if (actionRow.Length > 0)
|
||||||
|
buttons.Add(actionRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {
|
"Telegram": {
|
||||||
"BotToken": "",
|
"BotToken": "",
|
||||||
"MiniAppUrl": ""
|
"MiniAppUrl": ""
|
||||||
|
},
|
||||||
|
"Web": {
|
||||||
|
"BaseUrl": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public enum CalendarSubscriptionFilter
|
||||||
|
{
|
||||||
|
AllMyGroups = 0,
|
||||||
|
SpecificGroup = 1
|
||||||
|
}
|
||||||
@@ -7,8 +7,4 @@
|
|||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Заглушка для Discord-рендерера.
|
||||||
|
/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26).
|
||||||
|
/// </summary>
|
||||||
|
public static class DiscordSessionBatchRenderer
|
||||||
|
{
|
||||||
|
public static object Render(SessionBatchViewModel view)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException("Discord renderer will be implemented in issue #26.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
|
||||||
|
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
using GmRelay.Shared.Domain;
|
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Shared.Rendering;
|
|
||||||
|
|
||||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
|
|
||||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
|
||||||
|
|
||||||
public static class SessionBatchRenderer
|
|
||||||
{
|
|
||||||
public static (string Text, InlineKeyboardMarkup Markup) Render(
|
|
||||||
string title,
|
|
||||||
IReadOnlyList<SessionBatchDto> sessions,
|
|
||||||
IReadOnlyList<ParticipantBatchDto> participants)
|
|
||||||
{
|
|
||||||
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
|
|
||||||
|
|
||||||
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
|
|
||||||
$"<b>Расписание:</b>\n\n";
|
|
||||||
|
|
||||||
var buttons = new List<InlineKeyboardButton[]>();
|
|
||||||
|
|
||||||
foreach (var session in activeSessions)
|
|
||||||
{
|
|
||||||
var sessionPlayers = participants
|
|
||||||
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
|
||||||
.ToList();
|
|
||||||
var waitlistedPlayers = participants
|
|
||||||
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
|
||||||
messageText += session.MaxPlayers.HasValue
|
|
||||||
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
|
|
||||||
: $"👥 Игроки ({sessionPlayers.Count}):\n";
|
|
||||||
|
|
||||||
if (sessionPlayers.Count > 0)
|
|
||||||
{
|
|
||||||
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
messageText += " <i>Пока никто не записался</i>\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waitlistedPlayers.Count > 0)
|
|
||||||
{
|
|
||||||
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
|
|
||||||
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SessionStatus.IsCancelled(session.Status))
|
|
||||||
{
|
|
||||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
messageText += "\n";
|
|
||||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
|
||||||
buttons.Add(new[]
|
|
||||||
{
|
|
||||||
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
|
|
||||||
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
|
|
||||||
{
|
|
||||||
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
|
|
||||||
{
|
|
||||||
return $"⏳ В лист ожидания {dateTitle}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"✋ На {dateTitle}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
public static class SessionBatchViewBuilder
|
||||||
|
{
|
||||||
|
public static SessionBatchViewModel Build(
|
||||||
|
string title,
|
||||||
|
IReadOnlyList<SessionBatchDto> sessions,
|
||||||
|
IReadOnlyList<ParticipantBatchDto> participants)
|
||||||
|
{
|
||||||
|
var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
|
||||||
|
var sessionItems = new List<SessionViewItem>();
|
||||||
|
|
||||||
|
foreach (var session in orderedSessions)
|
||||||
|
{
|
||||||
|
var activePlayers = participants
|
||||||
|
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
||||||
|
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var waitlistedPlayers = participants
|
||||||
|
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
||||||
|
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var actions = new List<AvailableAction>();
|
||||||
|
if (!SessionStatus.IsCancelled(session.Status))
|
||||||
|
{
|
||||||
|
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||||
|
var joinLabel = GetJoinButtonText(session, activePlayers.Count, dateTitle);
|
||||||
|
actions.Add(new AvailableAction("join_session", joinLabel, session.SessionId));
|
||||||
|
actions.Add(new AvailableAction("leave_session", $"🚪 Выйти {dateTitle}", session.SessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionItems.Add(new SessionViewItem(
|
||||||
|
session.SessionId,
|
||||||
|
session.ScheduledAt,
|
||||||
|
session.Status,
|
||||||
|
session.MaxPlayers,
|
||||||
|
activePlayers.Count,
|
||||||
|
activePlayers,
|
||||||
|
waitlistedPlayers,
|
||||||
|
actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SessionBatchViewModel(title, sessionItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
|
||||||
|
{
|
||||||
|
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
|
||||||
|
{
|
||||||
|
return $"⏳ В лист ожидания {dateTitle}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"✋ На {dateTitle}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// trigger pr
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
public sealed record SessionBatchViewModel(
|
||||||
|
string Title,
|
||||||
|
IReadOnlyList<SessionViewItem> Sessions);
|
||||||
|
|
||||||
|
public sealed record SessionViewItem(
|
||||||
|
Guid SessionId,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int ActivePlayerCount,
|
||||||
|
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||||
|
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||||
|
IReadOnlyList<AvailableAction> AvailableActions);
|
||||||
|
|
||||||
|
public sealed record PlayerViewItem(
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
string RegistrationStatus);
|
||||||
|
|
||||||
|
public sealed record AvailableAction(
|
||||||
|
string ActionKey,
|
||||||
|
string Label,
|
||||||
|
Guid SessionId);
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
<a class="nav-brand" href="">
|
<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>
|
<span class="nav-brand-text">GM-Relay</span>
|
||||||
</a>
|
</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">
|
<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="6" x2="21" y2="6"/>
|
||||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
<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"/>
|
<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"/>
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
</svg>
|
</svg>
|
||||||
Панель управления
|
Главная страница
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
|
<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">
|
<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"/>
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
Выйти
|
Выход
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v1.9.6</div>
|
<div class="nav-version">v1.10.1</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<polyline points="10 17 15 12 10 7"/>
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
Войти
|
Вход
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</NotAuthorized>
|
</NotAuthorized>
|
||||||
|
|||||||
@@ -229,8 +229,13 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var session in sessions)
|
@foreach (var session in sessions)
|
||||||
{
|
{
|
||||||
|
var isExpanded = expandedSessions.Contains(session.Id);
|
||||||
<tr>
|
<tr>
|
||||||
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
<td style="color: var(--text-primary); font-weight: 500;">
|
||||||
|
<button type="button" class="btn-gm btn-gm-link" @onclick="() => ToggleParticipants(session.Id)">
|
||||||
|
@(isExpanded ? "▼" : "▶") @session.Title
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td>@session.ScheduledAt.FormatMoscow()</td>
|
<td>@session.ScheduledAt.FormatMoscow()</td>
|
||||||
<td>@FormatSeats(session)</td>
|
<td>@FormatSeats(session)</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -255,6 +260,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (isExpanded)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" style="padding: 0; border: none;">
|
||||||
|
<div class="participant-panel">
|
||||||
|
@if (loadingParticipantsSessionId == session.Id)
|
||||||
|
{
|
||||||
|
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||||
|
}
|
||||||
|
else if (participantsCache.TryGetValue(session.Id, out var participants))
|
||||||
|
{
|
||||||
|
@if (participants.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-state empty-state-compact">
|
||||||
|
<div class="empty-state-title">Нет участников</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="participant-list">
|
||||||
|
@foreach (var p in participants)
|
||||||
|
{
|
||||||
|
<div class="participant-row">
|
||||||
|
<div class="participant-info">
|
||||||
|
<span class="participant-name">@p.DisplayName</span>
|
||||||
|
<span class="participant-username">@FormatParticipantUsername(p)</span>
|
||||||
|
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
|
||||||
|
</div>
|
||||||
|
@if (!p.IsGm)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
|
||||||
|
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -264,9 +312,12 @@
|
|||||||
<div class="session-card-mobile stagger-children">
|
<div class="session-card-mobile stagger-children">
|
||||||
@foreach (var session in sessions)
|
@foreach (var session in sessions)
|
||||||
{
|
{
|
||||||
|
var isExpanded = expandedSessions.Contains(session.Id);
|
||||||
<div class="session-card">
|
<div class="session-card">
|
||||||
<div class="session-card-header">
|
<div class="session-card-header">
|
||||||
<span class="session-card-title">@session.Title</span>
|
<button type="button" class="btn-gm btn-gm-link" style="text-align: left; padding: 0;" @onclick="() => ToggleParticipants(session.Id)">
|
||||||
|
@(isExpanded ? "▼" : "▶") @session.Title
|
||||||
|
</button>
|
||||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-card-body">
|
<div class="session-card-body">
|
||||||
@@ -294,6 +345,45 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (isExpanded)
|
||||||
|
{
|
||||||
|
<div class="participant-panel" style="margin-top: 0.75rem;">
|
||||||
|
@if (loadingParticipantsSessionId == session.Id)
|
||||||
|
{
|
||||||
|
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||||
|
}
|
||||||
|
else if (participantsCache.TryGetValue(session.Id, out var participants))
|
||||||
|
{
|
||||||
|
@if (participants.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-state empty-state-compact">
|
||||||
|
<div class="empty-state-title">Нет участников</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="participant-list">
|
||||||
|
@foreach (var p in participants)
|
||||||
|
{
|
||||||
|
<div class="participant-row">
|
||||||
|
<div class="participant-info">
|
||||||
|
<span class="participant-name">@p.DisplayName</span>
|
||||||
|
<span class="participant-username">@FormatParticipantUsername(p)</span>
|
||||||
|
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
|
||||||
|
</div>
|
||||||
|
@if (!p.IsGm)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
|
||||||
|
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +406,10 @@
|
|||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CoGmEditModel coGmModel = new();
|
private CoGmEditModel coGmModel = new();
|
||||||
|
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||||
|
private HashSet<Guid> expandedSessions = new();
|
||||||
|
private Guid? kickingParticipantId;
|
||||||
|
private Guid? loadingParticipantsSessionId;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -447,6 +541,105 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ToggleParticipants(Guid sessionId)
|
||||||
|
{
|
||||||
|
if (expandedSessions.Contains(sessionId))
|
||||||
|
{
|
||||||
|
expandedSessions.Remove(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedSessions.Add(sessionId);
|
||||||
|
|
||||||
|
if (!participantsCache.ContainsKey(sessionId))
|
||||||
|
{
|
||||||
|
loadingParticipantsSessionId = sessionId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
|
||||||
|
participantsCache[sessionId] = participants ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось загрузить участников: " + ex.Message;
|
||||||
|
expandedSessions.Remove(sessionId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingParticipantsSessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task KickParticipant(Guid sessionId, Guid participantId)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
kickingParticipantId = participantId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
|
||||||
|
participantsCache.Remove(sessionId);
|
||||||
|
successMessage = "Игрок исключён.";
|
||||||
|
await LoadSessions();
|
||||||
|
if (expandedSessions.Contains(sessionId))
|
||||||
|
{
|
||||||
|
await ToggleParticipants(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
kickingParticipantId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatParticipantUsername(WebParticipant p)
|
||||||
|
{
|
||||||
|
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
|
||||||
|
? p.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
: "@" + p.TelegramUsername;
|
||||||
|
return $"{username} · {FormatParticipantRsvp(p.RsvpStatus)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatParticipantRsvp(string rsvp) => rsvp switch
|
||||||
|
{
|
||||||
|
RsvpStatus.Pending => "⏳ не ответил",
|
||||||
|
RsvpStatus.Confirmed => "✅ подтвердил",
|
||||||
|
RsvpStatus.Declined => "❌ отказался",
|
||||||
|
_ => rsvp
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetParticipantStatusClass(WebParticipant p)
|
||||||
|
{
|
||||||
|
if (p.IsGm) return "status-success";
|
||||||
|
return p.RegistrationStatus switch
|
||||||
|
{
|
||||||
|
"Active" => "status-info",
|
||||||
|
"Waitlisted" => "status-warning",
|
||||||
|
_ => "status-neutral"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TranslateParticipantStatus(WebParticipant p)
|
||||||
|
{
|
||||||
|
if (p.IsGm) return "ГМ";
|
||||||
|
return p.RegistrationStatus switch
|
||||||
|
{
|
||||||
|
"Active" => "Основной состав",
|
||||||
|
"Waitlisted" => "Ожидание",
|
||||||
|
_ => p.RegistrationStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
|
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
|||||||
@@ -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<TelegramAuthService>();
|
||||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||||
|
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||||
|
|
||||||
// Add Bot Client
|
// Add Bot Client
|
||||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||||
@@ -145,6 +146,24 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
|
|||||||
return Results.Redirect("/");
|
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();
|
app.Run();
|
||||||
|
|
||||||
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
|
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
|
||||||
|
|||||||
@@ -219,6 +219,28 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
|
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
|
||||||
|
{
|
||||||
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sessionStore.GetSessionParticipantsAsync(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
|
||||||
|
{
|
||||||
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||||
{
|
{
|
||||||
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
|
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles editing batch messages that may be either text or photo messages.
|
||||||
|
/// When the batch was created with SendPhoto (image + caption), we need
|
||||||
|
/// EditMessageCaption instead of EditMessageText.
|
||||||
|
/// </summary>
|
||||||
|
public static class BatchMessageEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Edits a batch message, automatically detecting whether it is a text or photo message.
|
||||||
|
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EditBatchMessageAsync(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
long chatId,
|
||||||
|
int messageId,
|
||||||
|
string text,
|
||||||
|
InlineKeyboardMarkup? replyMarkup,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
text: text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
|
||||||
|
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
|
||||||
|
await bot.EditMessageCaption(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
caption: caption,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
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
|
public interface ISessionStore
|
||||||
{
|
{
|
||||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||||
@@ -25,4 +38,7 @@ public interface ISessionStore
|
|||||||
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
|
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
|
||||||
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
|
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
|
||||||
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
|||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
using GmRelay.Web.Services;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
@@ -39,6 +40,16 @@ public sealed record WebSession(
|
|||||||
int WaitlistedPlayerCount,
|
int WaitlistedPlayerCount,
|
||||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
|
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
|
||||||
|
|
||||||
|
public sealed record WebParticipant(
|
||||||
|
Guid Id,
|
||||||
|
long TelegramId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
string RsvpStatus,
|
||||||
|
string RegistrationStatus,
|
||||||
|
bool IsGm,
|
||||||
|
DateTime? RespondedAt);
|
||||||
|
|
||||||
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||||
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
|
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||||
internal sealed record WebBatchInfo(
|
internal sealed record WebBatchInfo(
|
||||||
@@ -158,6 +169,39 @@ public sealed class SessionService(
|
|||||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
|
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(
|
public async Task AddGroupCoGmAsync(
|
||||||
Guid groupId,
|
Guid groupId,
|
||||||
long ownerTelegramId,
|
long ownerTelegramId,
|
||||||
@@ -507,6 +551,148 @@ public sealed class SessionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return (await conn.QueryAsync<WebParticipant>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS Id,
|
||||||
|
p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
sp.rsvp_status AS RsvpStatus,
|
||||||
|
sp.registration_status AS RegistrationStatus,
|
||||||
|
sp.is_gm AS IsGm,
|
||||||
|
sp.responded_at AS RespondedAt
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
ORDER BY sp.is_gm DESC,
|
||||||
|
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
|
||||||
|
sp.created_at
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId })).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var transaction = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
|
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.max_players AS MaxPlayers,
|
||||||
|
0 AS ActivePlayerCount,
|
||||||
|
0 AS WaitlistedPlayerCount,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
|
FOR UPDATE",
|
||||||
|
new { SessionId = sessionId, GroupId = groupId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS Id,
|
||||||
|
p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
sp.rsvp_status AS RsvpStatus,
|
||||||
|
sp.registration_status AS RegistrationStatus,
|
||||||
|
sp.is_gm AS IsGm,
|
||||||
|
sp.responded_at AS RespondedAt
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
|
||||||
|
""",
|
||||||
|
new { ParticipantId = participantId, SessionId = sessionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (participant is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Участник не найден в этой сессии.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
|
||||||
|
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM session_participants WHERE id = @ParticipantId",
|
||||||
|
new { ParticipantId = participantId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
WebPromotedParticipantDto? promoted = null;
|
||||||
|
|
||||||
|
if (wasActive)
|
||||||
|
{
|
||||||
|
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS ParticipantRowId,
|
||||||
|
p.display_name AS DisplayName
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
ORDER BY sp.created_at ASC, sp.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE OF sp
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (promoted is not null)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE session_participants
|
||||||
|
SET registration_status = @Active,
|
||||||
|
rsvp_status = @Pending,
|
||||||
|
responded_at = NULL
|
||||||
|
WHERE id = @ParticipantRowId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
promoted.ParticipantRowId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Pending = RsvpStatus.Pending
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
await bot.SendMessage(
|
||||||
|
session.TelegramChatId,
|
||||||
|
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
|
if (promoted is not null)
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
session.TelegramChatId,
|
||||||
|
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
|
if (session.BatchMessageId.HasValue)
|
||||||
|
{
|
||||||
|
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (session.BatchMessageId.HasValue)
|
||||||
|
{
|
||||||
|
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
|
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
@@ -731,7 +917,8 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
var batchMessage = await bot.SendMessage(
|
var batchMessage = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageThreadId: threadId,
|
messageThreadId: threadId,
|
||||||
@@ -939,7 +1126,8 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
var batchMessage = await bot.SendMessage(
|
var batchMessage = await bot.SendMessage(
|
||||||
chatId: group.TelegramChatId,
|
chatId: group.TelegramChatId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
@@ -1046,13 +1234,14 @@ public sealed class SessionService(
|
|||||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||||
new { BatchId = batchId })).ToList();
|
new { BatchId = batchId })).ToList();
|
||||||
|
|
||||||
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
|
var view = SessionBatchViewBuilder.Build(title, sessions, participants);
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup);
|
replyMarkup: renderResult.Markup);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public sealed class SubscriptionNotFoundException : Exception
|
||||||
|
{
|
||||||
|
public SubscriptionNotFoundException() : base("Calendar subscription not found.") { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public static class TelegramSessionBatchRenderer
|
||||||
|
{
|
||||||
|
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
|
||||||
|
{
|
||||||
|
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
|
||||||
|
$"<b>Расписание:</b>\n\n";
|
||||||
|
|
||||||
|
var buttons = new List<InlineKeyboardButton[]>();
|
||||||
|
|
||||||
|
foreach (var session in view.Sessions)
|
||||||
|
{
|
||||||
|
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||||
|
messageText += session.MaxPlayers.HasValue
|
||||||
|
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
|
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||||
|
|
||||||
|
if (session.ActivePlayers.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||||
|
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messageText += " <i>Пока никто не записался</i>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.WaitlistedPlayers.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||||
|
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||||
|
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
|
||||||
|
{
|
||||||
|
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messageText += "\n";
|
||||||
|
var actionRow = session.AvailableActions
|
||||||
|
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
|
||||||
|
.ToArray();
|
||||||
|
if (actionRow.Length > 0)
|
||||||
|
buttons.Add(actionRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GM-Relay Design System v1.9.6
|
GM-Relay Design System v1.9.9
|
||||||
Dark RPG Dashboard Theme
|
Dark RPG Dashboard Theme
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@@ -1115,3 +1115,56 @@ body.telegram-mini-app .session-card-mobile {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Participant list === */
|
||||||
|
.participant-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-username {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-link {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Landing;
|
||||||
|
|
||||||
|
public sealed class TelegramLandingPromisesSmokeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
|
||||||
|
{
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
|
||||||
|
|
||||||
|
Assert.True(parseResult.IsValid);
|
||||||
|
Assert.Equal(3, parseResult.ScheduledTimes.Count);
|
||||||
|
Assert.Equal(2, parseResult.MaxPlayers);
|
||||||
|
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
|
||||||
|
|
||||||
|
var scenario = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
|
||||||
|
var publishedCallbacks = CallbackData(scenario.LastMessage.Markup);
|
||||||
|
|
||||||
|
Assert.Contains("Landing Promise Smoke", scenario.LastMessage.Text);
|
||||||
|
Assert.Equal(6, publishedCallbacks.Count);
|
||||||
|
Assert.All(scenario.Sessions, session =>
|
||||||
|
{
|
||||||
|
Assert.Contains($"join_session:{session.Id}", publishedCallbacks);
|
||||||
|
Assert.Contains($"leave_session:{session.Id}", publishedCallbacks);
|
||||||
|
Assert.Contains(session.ScheduledAt.FormatMoscow(), scenario.LastMessage.Text);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/2", 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);
|
||||||
|
|
||||||
|
scenario.MarkRsvpConfirmed(firstSessionId, bob);
|
||||||
|
scenario.MarkRsvpConfirmed(firstSessionId, carol);
|
||||||
|
|
||||||
|
var option1Id = Guid.NewGuid();
|
||||||
|
var option2Id = Guid.NewGuid();
|
||||||
|
var options = new[]
|
||||||
|
{
|
||||||
|
new RescheduleOptionDto(option1Id, 1, new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero)),
|
||||||
|
new RescheduleOptionDto(option2Id, 2, new DateTimeOffset(2026, 5, 30, 15, 0, 0, TimeSpan.Zero))
|
||||||
|
};
|
||||||
|
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
||||||
|
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
||||||
|
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
|
scenario.Title,
|
||||||
|
scenario.Sessions[0].ScheduledAt,
|
||||||
|
deadline,
|
||||||
|
options,
|
||||||
|
voteParticipants,
|
||||||
|
[]);
|
||||||
|
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
||||||
|
|
||||||
|
Assert.Contains("Landing Promise Smoke", voteMessage);
|
||||||
|
Assert.Contains("0/2", voteMessage);
|
||||||
|
Assert.Contains($"reschedule_vote:{option1Id}", CallbackData(voteKeyboard));
|
||||||
|
Assert.Contains($"reschedule_vote:{option2Id}", CallbackData(voteKeyboard));
|
||||||
|
|
||||||
|
var votes = voteParticipants
|
||||||
|
.Select(participant => new RescheduleOptionVoteDto(
|
||||||
|
option2Id,
|
||||||
|
participant.PlayerId,
|
||||||
|
participant.DisplayName,
|
||||||
|
participant.TelegramUsername))
|
||||||
|
.ToList();
|
||||||
|
var decision = RescheduleVoteRules.SelectWinner(
|
||||||
|
options.Select(option => new RescheduleOptionVoteCount(
|
||||||
|
option.OptionId,
|
||||||
|
votes.Count(vote => vote.OptionId == option.OptionId))).ToList());
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
|
||||||
|
Assert.Equal(option2Id, decision.SelectedOptionId);
|
||||||
|
|
||||||
|
scenario.ApplyReschedule(firstSessionId, options[1].ProposedAt);
|
||||||
|
|
||||||
|
Assert.Equal(options[1].ProposedAt.UtcDateTime, scenario.Sessions[0].ScheduledAt);
|
||||||
|
Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob));
|
||||||
|
Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol));
|
||||||
|
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), scenario.LastMessage.Text);
|
||||||
|
Assert.Collection(
|
||||||
|
scenario.Messenger.DirectMessages.Select(message => message.TelegramId).Order(),
|
||||||
|
telegramId => Assert.Equal(1002, telegramId),
|
||||||
|
telegramId => Assert.Equal(1003, telegramId));
|
||||||
|
Assert.All(scenario.Messenger.DirectMessages, message =>
|
||||||
|
{
|
||||||
|
Assert.Contains("Landing Promise Smoke", message.Text);
|
||||||
|
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), message.Text);
|
||||||
|
});
|
||||||
|
|
||||||
|
var editsBeforeDashboardUpdate = scenario.Messenger.Edits.Count;
|
||||||
|
|
||||||
|
scenario.UpdateBatchFromDashboard("Landing Promise Smoke - Dashboard Sync");
|
||||||
|
|
||||||
|
Assert.True(scenario.Messenger.Edits.Count > editsBeforeDashboardUpdate);
|
||||||
|
Assert.Contains("Landing Promise Smoke - Dashboard Sync", scenario.LastMessage.Text);
|
||||||
|
Assert.Contains("@bob", scenario.LastMessage.Text);
|
||||||
|
Assert.Contains("@carol", scenario.LastMessage.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildRecurringSessionCommand() =>
|
||||||
|
string.Join(
|
||||||
|
'\n',
|
||||||
|
"/newsession",
|
||||||
|
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
|
||||||
|
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
|
||||||
|
"\u0418\u0433\u0440: 3",
|
||||||
|
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
|
||||||
|
"\u041c\u0435\u0441\u0442: 2",
|
||||||
|
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
|
||||||
|
markup.InlineKeyboard
|
||||||
|
.SelectMany(row => row)
|
||||||
|
.Select(button => button.CallbackData)
|
||||||
|
.OfType<string>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private sealed class TelegramLandingSmokeScenario
|
||||||
|
{
|
||||||
|
private readonly List<SmokeSession> sessions;
|
||||||
|
private readonly List<SmokeParticipant> participants = [];
|
||||||
|
private readonly SessionNotificationMode notificationMode;
|
||||||
|
|
||||||
|
private TelegramLandingSmokeScenario(
|
||||||
|
string title,
|
||||||
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
|
int? maxPlayers,
|
||||||
|
SessionNotificationMode notificationMode)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
this.notificationMode = notificationMode;
|
||||||
|
sessions = scheduledTimes
|
||||||
|
.Select(scheduledAt => new SmokeSession(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
scheduledAt.UtcDateTime,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
maxPlayers))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Title { get; private set; }
|
||||||
|
public FakeTelegramMessenger Messenger { get; } = new();
|
||||||
|
public IReadOnlyList<SmokeSession> Sessions => sessions;
|
||||||
|
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
|
||||||
|
|
||||||
|
public static TelegramLandingSmokeScenario Publish(
|
||||||
|
NewSessionParseResult parseResult,
|
||||||
|
SessionNotificationMode notificationMode)
|
||||||
|
{
|
||||||
|
var scenario = new TelegramLandingSmokeScenario(
|
||||||
|
parseResult.Title!,
|
||||||
|
parseResult.ScheduledTimes,
|
||||||
|
parseResult.MaxPlayers,
|
||||||
|
notificationMode);
|
||||||
|
|
||||||
|
scenario.RenderBatch();
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Join(Guid sessionId, long telegramId, string displayName, string? telegramUsername)
|
||||||
|
{
|
||||||
|
var session = sessions.Single(value => value.Id == sessionId);
|
||||||
|
var activeParticipants = participants.Count(participant =>
|
||||||
|
participant.SessionId == sessionId &&
|
||||||
|
participant.RegistrationStatus == ParticipantRegistrationStatus.Active);
|
||||||
|
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
|
||||||
|
session.MaxPlayers,
|
||||||
|
activeParticipants);
|
||||||
|
var playerId = Guid.NewGuid();
|
||||||
|
|
||||||
|
participants.Add(new SmokeParticipant(
|
||||||
|
sessionId,
|
||||||
|
playerId,
|
||||||
|
telegramId,
|
||||||
|
displayName,
|
||||||
|
telegramUsername,
|
||||||
|
registrationStatus,
|
||||||
|
GmRelay.Shared.Domain.RsvpStatus.Pending,
|
||||||
|
participants.Count));
|
||||||
|
|
||||||
|
RenderBatch();
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Leave(Guid sessionId, Guid playerId)
|
||||||
|
{
|
||||||
|
var participant = participants.Single(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.PlayerId == playerId);
|
||||||
|
participants.Remove(participant);
|
||||||
|
|
||||||
|
var session = sessions.Single(value => value.Id == sessionId);
|
||||||
|
var activeParticipantsAfterLeave = participants.Count(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.RegistrationStatus == ParticipantRegistrationStatus.Active);
|
||||||
|
var waitlistedParticipants = participants.Count(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted);
|
||||||
|
|
||||||
|
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||||
|
participant.RegistrationStatus,
|
||||||
|
session.MaxPlayers,
|
||||||
|
activeParticipantsAfterLeave,
|
||||||
|
waitlistedParticipants))
|
||||||
|
{
|
||||||
|
var promoted = participants
|
||||||
|
.Where(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
||||||
|
.OrderBy(value => value.JoinOrder)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
promoted.RegistrationStatus = ParticipantRegistrationStatus.Active;
|
||||||
|
promoted.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasParticipant(Guid sessionId, Guid playerId) =>
|
||||||
|
participants.Any(value => value.SessionId == sessionId && value.PlayerId == playerId);
|
||||||
|
|
||||||
|
public string RegistrationStatus(Guid sessionId, Guid playerId) =>
|
||||||
|
participants.Single(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.PlayerId == playerId).RegistrationStatus;
|
||||||
|
|
||||||
|
public string RsvpStatus(Guid sessionId, Guid playerId) =>
|
||||||
|
participants.Single(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.PlayerId == playerId).RsvpStatus;
|
||||||
|
|
||||||
|
public void MarkRsvpConfirmed(Guid sessionId, Guid playerId)
|
||||||
|
{
|
||||||
|
participants.Single(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.PlayerId == playerId).RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<VoteParticipantDto> ActiveVoteParticipants(Guid sessionId) =>
|
||||||
|
participants
|
||||||
|
.Where(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
||||||
|
.OrderBy(value => value.DisplayName)
|
||||||
|
.Select(value => new VoteParticipantDto(
|
||||||
|
value.PlayerId,
|
||||||
|
value.DisplayName,
|
||||||
|
value.TelegramUsername,
|
||||||
|
value.TelegramId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public void ApplyReschedule(Guid sessionId, DateTimeOffset newScheduledAt)
|
||||||
|
{
|
||||||
|
sessions.Single(value => value.Id == sessionId).ScheduledAt = newScheduledAt.UtcDateTime;
|
||||||
|
|
||||||
|
foreach (var participant in participants.Where(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
|
||||||
|
{
|
||||||
|
participant.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderBatch();
|
||||||
|
|
||||||
|
if (!notificationMode.ShouldSendDirectMessages())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification = $"""
|
||||||
|
Reschedule approved
|
||||||
|
{Title}
|
||||||
|
{newScheduledAt.UtcDateTime.FormatMoscow()}
|
||||||
|
""";
|
||||||
|
foreach (var participant in participants.Where(value =>
|
||||||
|
value.SessionId == sessionId &&
|
||||||
|
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
|
||||||
|
{
|
||||||
|
Messenger.SendDirectMessage(participant.TelegramId, notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateBatchFromDashboard(string title)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
RenderBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderBatch()
|
||||||
|
{
|
||||||
|
var view = SessionBatchViewBuilder.Build(
|
||||||
|
Title,
|
||||||
|
sessions
|
||||||
|
.Select(session => new SessionBatchDto(
|
||||||
|
session.Id,
|
||||||
|
session.ScheduledAt,
|
||||||
|
session.Status,
|
||||||
|
session.MaxPlayers))
|
||||||
|
.ToList(),
|
||||||
|
participants
|
||||||
|
.Select(participant => new ParticipantBatchDto(
|
||||||
|
participant.SessionId,
|
||||||
|
participant.DisplayName,
|
||||||
|
participant.TelegramUsername,
|
||||||
|
participant.RegistrationStatus))
|
||||||
|
.ToList());
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
if (Messenger.HasPublishedMessage)
|
||||||
|
{
|
||||||
|
Messenger.EditBatchMessage(renderResult.Text, renderResult.Markup);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Messenger.PublishBatchMessage(renderResult.Text, renderResult.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTelegramMessenger
|
||||||
|
{
|
||||||
|
private const int BatchMessageId = 7001;
|
||||||
|
|
||||||
|
public List<FakeTelegramMessage> Sends { get; } = [];
|
||||||
|
public List<FakeTelegramMessage> Edits { get; } = [];
|
||||||
|
public List<FakeDirectMessage> DirectMessages { get; } = [];
|
||||||
|
public bool HasPublishedMessage => Sends.Count > 0;
|
||||||
|
public FakeTelegramMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1];
|
||||||
|
|
||||||
|
public void PublishBatchMessage(string text, InlineKeyboardMarkup markup) =>
|
||||||
|
Sends.Add(new FakeTelegramMessage(BatchMessageId, text, markup));
|
||||||
|
|
||||||
|
public void EditBatchMessage(string text, InlineKeyboardMarkup markup) =>
|
||||||
|
Edits.Add(new FakeTelegramMessage(BatchMessageId, text, markup));
|
||||||
|
|
||||||
|
public void SendDirectMessage(long telegramId, string text) =>
|
||||||
|
DirectMessages.Add(new FakeDirectMessage(telegramId, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FakeTelegramMessage(
|
||||||
|
int MessageId,
|
||||||
|
string Text,
|
||||||
|
InlineKeyboardMarkup Markup);
|
||||||
|
|
||||||
|
private sealed record FakeDirectMessage(long TelegramId, string Text);
|
||||||
|
|
||||||
|
private sealed record SmokeSession(
|
||||||
|
Guid Id,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
int? MaxPlayers)
|
||||||
|
{
|
||||||
|
public DateTime ScheduledAt { get; set; } = ScheduledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SmokeParticipant(
|
||||||
|
Guid SessionId,
|
||||||
|
Guid PlayerId,
|
||||||
|
long TelegramId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
string RegistrationStatus,
|
||||||
|
string RsvpStatus,
|
||||||
|
int JoinOrder)
|
||||||
|
{
|
||||||
|
public string RegistrationStatus { get; set; } = RegistrationStatus;
|
||||||
|
public string RsvpStatus { get; set; } = RsvpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Rendering;
|
|
||||||
|
|
||||||
public sealed class SessionBatchRendererTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
|
|
||||||
{
|
|
||||||
var firstSessionId = Guid.NewGuid();
|
|
||||||
var secondSessionId = Guid.NewGuid();
|
|
||||||
var cancelledSessionId = Guid.NewGuid();
|
|
||||||
|
|
||||||
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 result = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
|
||||||
var text = result.Text;
|
|
||||||
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
|
|
||||||
|
|
||||||
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
|
||||||
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
|
||||||
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
|
||||||
|
|
||||||
Assert.Contains("Campaign", text);
|
|
||||||
Assert.True(firstIndex < secondIndex);
|
|
||||||
Assert.True(secondIndex < thirdIndex);
|
|
||||||
Assert.Contains("Места: 0/2", text);
|
|
||||||
Assert.Contains("Места: 1/4", text);
|
|
||||||
Assert.Contains("@alice", text);
|
|
||||||
Assert.Contains("Лист ожидания (1)", text);
|
|
||||||
Assert.Contains("Charlie", text);
|
|
||||||
Assert.Contains("Bob", text);
|
|
||||||
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
|
|
||||||
Assert.Collection(
|
|
||||||
buttons.Select(button => button.CallbackData),
|
|
||||||
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
|
|
||||||
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
|
|
||||||
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
|
|
||||||
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData));
|
|
||||||
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true);
|
|
||||||
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true);
|
|
||||||
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class SessionBatchViewBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldOrderSessionsByDate()
|
||||||
|
{
|
||||||
|
var firstSessionId = Guid.NewGuid();
|
||||||
|
var secondSessionId = Guid.NewGuid();
|
||||||
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
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 result = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||||
|
|
||||||
|
Assert.Equal("Campaign", result.Title);
|
||||||
|
Assert.Equal(3, result.Sessions.Count);
|
||||||
|
Assert.Equal(firstSessionId, result.Sessions[0].SessionId);
|
||||||
|
Assert.Equal(secondSessionId, result.Sessions[1].SessionId);
|
||||||
|
Assert.Equal(cancelledSessionId, result.Sessions[2].SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldCalculatePlayerCounts()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
|
||||||
|
var participants = new[]
|
||||||
|
{
|
||||||
|
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||||
|
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Active),
|
||||||
|
new ParticipantBatchDto(sessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var session = result.Sessions[0];
|
||||||
|
|
||||||
|
Assert.Equal(2, session.ActivePlayerCount);
|
||||||
|
Assert.Equal(4, session.MaxPlayers);
|
||||||
|
Assert.True(session.ActivePlayers.Count == 2);
|
||||||
|
Assert.True(session.WaitlistedPlayers.Count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldIncludeActionsForActiveSessions()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
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(sessionId, actions[0].SessionId);
|
||||||
|
Assert.Equal(sessionId, actions[1].SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldNotIncludeActionsForCancelledSessions()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
|
||||||
|
Assert.Empty(result.Sessions[0].AvailableActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldMarkWaitlistActionWhenFull()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
|
||||||
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
|
||||||
|
|
||||||
|
Assert.Contains("ожидания", joinAction.Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldIncludePlayerUsernames()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) };
|
||||||
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var player = result.Sessions[0].ActivePlayers[0];
|
||||||
|
|
||||||
|
Assert.Equal("Alice", player.DisplayName);
|
||||||
|
Assert.Equal("alice", player.TelegramUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class TelegramSessionBatchRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldProduceCorrectHtmlAndButtons()
|
||||||
|
{
|
||||||
|
var firstSessionId = Guid.NewGuid();
|
||||||
|
var secondSessionId = Guid.NewGuid();
|
||||||
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
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 view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||||
|
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("Campaign", text);
|
||||||
|
Assert.Contains("@alice", text);
|
||||||
|
Assert.Contains("Charlie", text);
|
||||||
|
Assert.Contains("Bob", text);
|
||||||
|
Assert.Contains("Сессия отменена", text);
|
||||||
|
|
||||||
|
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
|
||||||
|
|
||||||
|
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]
|
||||||
|
public void Render_ShouldSkipButtonsForCancelledSessions()
|
||||||
|
{
|
||||||
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Empty(markup.InlineKeyboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
|
||||||
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
var joinButton = buttons.First(b => b.CallbackData?.StartsWith("join_session:") == true);
|
||||||
|
|
||||||
|
Assert.Contains("ожидания", joinButton.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -655,6 +655,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public string? LastAddedCoGmUsername { get; private set; }
|
public string? LastAddedCoGmUsername { get; private set; }
|
||||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||||
public long? LastRemovedCoGmTelegramId { get; private set; }
|
public long? LastRemovedCoGmTelegramId { get; private set; }
|
||||||
|
public bool RemovePlayerCalled { get; private set; }
|
||||||
|
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||||
|
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||||
|
public Guid? LastRemovedPlayerParticipantId { get; private set; }
|
||||||
|
|
||||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||||
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
|
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
|
||||||
@@ -870,6 +874,21 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) =>
|
||||||
|
Task.FromResult(new List<WebParticipant>());
|
||||||
|
|
||||||
|
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
|
||||||
|
{
|
||||||
|
RemovePlayerCalled = true;
|
||||||
|
LastRemovedPlayerSessionId = sessionId;
|
||||||
|
LastRemovedPlayerGroupId = groupId;
|
||||||
|
LastRemovedPlayerParticipantId = participantId;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) =>
|
||||||
|
Task.FromResult(new List<PlayerAttendanceStats>());
|
||||||
|
|
||||||
private bool IsManager(Guid groupId, long telegramId) =>
|
private bool IsManager(Guid groupId, long telegramId) =>
|
||||||
IsOwner(groupId, telegramId) ||
|
IsOwner(groupId, telegramId) ||
|
||||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Web;
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
public sealed class CampaignTemplatesNavigationTests
|
public sealed class CampaignTemplatesNavigationTests
|
||||||
@@ -11,6 +13,20 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
|
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||||
|
{
|
||||||
|
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||||
|
var props = XDocument.Load(FindRepositoryFile("Directory.Build.props"));
|
||||||
|
var version = props.Root?
|
||||||
|
.Element("PropertyGroup")?
|
||||||
|
.Element("Version")?
|
||||||
|
.Value;
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||||
|
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
|
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user