Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c737dbf45 | |||
| 52b7b3e0fd |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.10.0
|
VERSION: 1.9.9
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
# Issue #19: Выровнять /newsession с batch-сценарием лендинга
|
|
||||||
|
|
||||||
> **Goal:** Сделать `/newsession` атомарным: либо весь batch создаётся целиком, либо ничего — с понятным сообщением об ошибках. Убрать создание частичных сессий при наличии любых ошибок ввода.
|
|
||||||
|
|
||||||
**Architecture:** Изменить `NewSessionParseResult.IsValid` так, чтобы он учитывал ЛЮБЫЕ parse-ошибки (некорректные даты, лимиты, повторы, прошедшие даты). Обновить `CreateSessionHandler`, чтобы при `!IsValid` отправлялось одно детальное сообщение с перечислением ошибок и help-шаблоном, и creation прерывался до транзакции БД.
|
|
||||||
|
|
||||||
**Tech Stack:** C#, .NET, Dapper, Telegram.Bot, xUnit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Добавить `HasErrors` и ужесточить `IsValid`
|
|
||||||
|
|
||||||
**Objective:** Запретить частичное создание: `IsValid == false`, если есть хоть одна ошибка парсинга.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs`
|
|
||||||
- Test: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs`
|
|
||||||
|
|
||||||
**Step 1: RED — написать failing test**
|
|
||||||
|
|
||||||
Добавить в конец `NewSessionCommandParserTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void Parse_ShouldBeInvalid_WhenAnyErrorsPresent()
|
|
||||||
{
|
|
||||||
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
|
|
||||||
var text = """
|
|
||||||
Название: Delta Green
|
|
||||||
Время: 25.04.2026 19:30
|
|
||||||
Время: 31.04.2026 19:30
|
|
||||||
Ссылка: https://example.test/dg
|
|
||||||
""";
|
|
||||||
|
|
||||||
var result = NewSessionCommandParser.Parse(text, nowUtc);
|
|
||||||
|
|
||||||
Assert.True(result.HasErrors);
|
|
||||||
Assert.False(result.IsValid);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run test and verify RED**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n
|
|
||||||
```
|
|
||||||
Expected: `HasErrors` property not found → compile error or FAIL.
|
|
||||||
|
|
||||||
**Step 3: GREEN — minimal implementation**
|
|
||||||
|
|
||||||
В `NewSessionCommandParser.cs`, в `NewSessionParseResult`, заменить `IsValid` на:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public bool HasErrors =>
|
|
||||||
PastTimeInputs.Count > 0 ||
|
|
||||||
InvalidTimeInputs.Count > 0 ||
|
|
||||||
InvalidSeatLimitInputs.Count > 0 ||
|
|
||||||
InvalidRecurringInputs.Count > 0;
|
|
||||||
|
|
||||||
public bool IsValid =>
|
|
||||||
!string.IsNullOrWhiteSpace(Title) &&
|
|
||||||
!string.IsNullOrWhiteSpace(Link) &&
|
|
||||||
ScheduledTimes.Count > 0 &&
|
|
||||||
!HasErrors;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Run test and verify GREEN**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n
|
|
||||||
```
|
|
||||||
Expected: 7 passed (включая новый).
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs
|
|
||||||
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
|
|
||||||
git commit -m "feat(#19): add HasErrors to prevent partial batch creation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Обновить существующий тест на Past/Invalid times
|
|
||||||
|
|
||||||
**Objective:** Старый тест ожидал `IsValid == true` при partial invalid — теперь это баг, нужно обновить assertion.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs:114`
|
|
||||||
|
|
||||||
**Step 1: Write failing expectation first**
|
|
||||||
|
|
||||||
Заменить в методе `Parse_ShouldCollectPastAndInvalidTimes` (примерно строка 114):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Было:
|
|
||||||
Assert.True(result.IsValid);
|
|
||||||
// Стало:
|
|
||||||
Assert.False(result.IsValid);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run test and verify RED**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n
|
|
||||||
```
|
|
||||||
Expected: FAIL — expected False, actual True.
|
|
||||||
|
|
||||||
**Step 3: Fix already done in Task 1**
|
|
||||||
|
|
||||||
Task 1 уже изменил `IsValid`. Перезапустить тест.
|
|
||||||
|
|
||||||
**Step 4: Run test and verify GREEN**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n
|
|
||||||
```
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
|
|
||||||
git commit -m "test(#19): update assertion - partial invalid means invalid"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Обновить `CreateSessionHandler` для атомарной валидации и единого сообщения об ошибках
|
|
||||||
|
|
||||||
**Objective:** Убрать разбросанные warning-сообщения. При любых ошибках — одно сообщение с перечнем ошибок + help-шаблон. Не создавать сессии.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
|
|
||||||
|
|
||||||
**Step 1: RED — напишем интеграционный поведенческий тест (опционально, landing test уже покрывает happy path)**
|
|
||||||
|
|
||||||
Просто запустим landing promise smoke test и убедимся, что он ещё green (happy path не сломан).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "TelegramLandingPromisesSmokeTests" -v n
|
|
||||||
```
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
**Step 2: GREEN — minimal change in handler**
|
|
||||||
|
|
||||||
В `CreateSessionHandler.cs`, метод `HandleAsync`, заменить начало (строки 19–59) на:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
var errorMessages = new List<string>();
|
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.PastTimeInputs)
|
|
||||||
errorMessages.Add($"⚠️ Дата {timeInput} находится в прошлом и будет пропущена.");
|
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
|
||||||
errorMessages.Add($"⚠️ Некорректный формат времени '{timeInput}'. Пропущено.");
|
|
||||||
|
|
||||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
|
||||||
errorMessages.Add($"⚠️ Некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.");
|
|
||||||
|
|
||||||
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
|
||||||
errorMessages.Add($"⚠️ Некорректный повтор расписания '{recurringInput}'. Укажите число игр 1–52 и шаг 1–365 дней.");
|
|
||||||
|
|
||||||
if (!parseResult.IsValid)
|
|
||||||
{
|
|
||||||
var helpText = """
|
|
||||||
❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:
|
|
||||||
|
|
||||||
/newsession
|
|
||||||
Название: My Game
|
|
||||||
Время: 15.05.2026 19:30
|
|
||||||
Время: 22.05.2026 19:30
|
|
||||||
Мест: 4
|
|
||||||
Ссылка: https://link
|
|
||||||
Картинка: https://cover
|
|
||||||
|
|
||||||
Для повтора можно указать одну дату и строки:
|
|
||||||
Игр: 4
|
|
||||||
Интервал: 7
|
|
||||||
""";
|
|
||||||
|
|
||||||
if (errorMessages.Count > 0)
|
|
||||||
{
|
|
||||||
helpText = string.Join('\n', errorMessages) + "\n\n" + helpText;
|
|
||||||
}
|
|
||||||
|
|
||||||
await botClient.SendMessage(
|
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: helpText,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Build and verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet build src/GmRelay.Bot
|
|
||||||
```
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
**Step 4: Run all relevant tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~CreateSession|FullyQualifiedName~Landing" -v n
|
|
||||||
```
|
|
||||||
Expected: All passed.
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
|
|
||||||
git commit -m "feat(#19): atomic validation with detailed error message in /newsession"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Проверить полный test suite на регрессии
|
|
||||||
|
|
||||||
**Objective:** Убедиться, что изменения не сломали существующие тесты.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: все тесты проекта `GmRelay.Bot.Tests`
|
|
||||||
|
|
||||||
**Step 1: Run full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /repo && dotnet test tests/GmRelay.Bot.Tests -v n
|
|
||||||
```
|
|
||||||
Expected: All passed.
|
|
||||||
|
|
||||||
**Step 2: Commit (if any test baseline updated)**
|
|
||||||
|
|
||||||
Если всё зелёное — commit message:
|
|
||||||
```bash
|
|
||||||
git commit --allow-empty -m "test(#19): verify full suite green after atomic validation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
- [ ] `NewSessionCommandParser.Parse` возвращает `IsValid = false` при любых ошибках (past/invalid times, seat limits, recurring).
|
|
||||||
- [ ] `HasErrors` true ↔ есть хотя бы одна ошибка.
|
|
||||||
- [ ] `CreateSessionHandler` не открывает БД-транзакцию, если `!IsValid`.
|
|
||||||
- [ ] Пользователь получает одно сообщение с перечислением ошибок + help.
|
|
||||||
- [ ] Landing promise smoke test проходит (happy path не сломан).
|
|
||||||
- [ ] Полный test suite зелёный.
|
|
||||||
@@ -0,0 +1,752 @@
|
|||||||
|
# Issue #23: Добавить platform identity и platform_messages в БД
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Расширить PostgreSQL-схему нейтральными platform-полями и таблицей platform_messages, чтобы Discord-адаптер мог работать без чтения `telegram_*` legacy-полей. Сохранить обратную совместимость с Telegram-сценариями.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- Добавляем generic `platform` enum-like поля (`VARCHAR(50)`) + `external_*` ID-поля рядом с legacy `telegram_*`.
|
||||||
|
- Создаём таблицу `platform_messages` для отслеживания batch-сообщений на любой платформе.
|
||||||
|
- Миграция обратима: `telegram_*` НЕ удаляются, новые поля nullable/с дефолтами.
|
||||||
|
- После миграции обновляем SQL-запросы в handlers (Task 8-12) чтобы сначала писать и читать через новые поля.
|
||||||
|
|
||||||
|
**Tech Stack:** PostgreSQL 16+, DbUp, Dapper, .NET 10, Npgsql.
|
||||||
|
|
||||||
|
**Milestone:** Версия 2.0 — Discord Bot MVP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Написать миграцию V011__add_platform_identity.sql
|
||||||
|
|
||||||
|
**Objective:** Создать обратимую миграцию, добавляющую platform-поля и таблицу platform_messages.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql`
|
||||||
|
|
||||||
|
**Step 1: Write failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверяем, что миграция ещё не существует
|
||||||
|
ls src/GmRelay.Bot/Migrations/V011__*.sql
|
||||||
|
# Expected: FAIL — file not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write migration script**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- =============================================================
|
||||||
|
-- V011: Add platform identity fields for multi-platform support
|
||||||
|
-- Telegram remains default platform; old telegram_* fields kept.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1) players: add platform identity columns
|
||||||
|
ALTER TABLE players
|
||||||
|
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
|
||||||
|
ADD COLUMN IF NOT EXISTS external_user_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255);
|
||||||
|
|
||||||
|
-- Populate external_user_id/external_username from existing telegram data
|
||||||
|
UPDATE players
|
||||||
|
SET external_user_id = telegram_id::VARCHAR(255),
|
||||||
|
external_username = telegram_username
|
||||||
|
WHERE external_user_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_players_platform_external_user
|
||||||
|
ON players (platform, external_user_id);
|
||||||
|
|
||||||
|
-- 2) game_groups: add platform identity columns
|
||||||
|
ALTER TABLE game_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
|
||||||
|
ADD COLUMN IF NOT EXISTS external_group_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE game_groups
|
||||||
|
SET external_group_id = telegram_chat_id::VARCHAR(255)
|
||||||
|
WHERE external_group_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_game_groups_platform_external_group
|
||||||
|
ON game_groups (platform, external_group_id);
|
||||||
|
|
||||||
|
-- 3) sessions: add external_channel_id for platform-neutral thread/channel tracking
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE sessions
|
||||||
|
SET external_channel_id = thread_id::VARCHAR(255)
|
||||||
|
WHERE external_channel_id IS NULL AND thread_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 4) platform_messages: new table for tracking messages across platforms
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform VARCHAR(50) NOT NULL,
|
||||||
|
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||||
|
batch_id UUID NOT NULL,
|
||||||
|
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
external_channel_id VARCHAR(255),
|
||||||
|
external_thread_id VARCHAR(255),
|
||||||
|
external_message_id VARCHAR(255) NOT NULL,
|
||||||
|
purpose VARCHAR(50) NOT NULL DEFAULT 'schedule'
|
||||||
|
CHECK (purpose IN ('schedule','reminder','confirmation','reschedule_vote','direct_notification')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_platform_messages_group_batch
|
||||||
|
ON platform_messages (group_id, batch_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_platform_messages_session
|
||||||
|
ON platform_messages (session_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_platform_messages_purpose
|
||||||
|
ON platform_messages (purpose);
|
||||||
|
|
||||||
|
-- 5) Backfill platform_messages from existing sessions.batch_message_id data
|
||||||
|
INSERT INTO platform_messages (platform, group_id, batch_id, session_id, external_channel_id, external_message_id, purpose)
|
||||||
|
SELECT 'telegram',
|
||||||
|
s.group_id,
|
||||||
|
s.batch_id,
|
||||||
|
s.id,
|
||||||
|
NULL,
|
||||||
|
s.batch_message_id::VARCHAR(255),
|
||||||
|
'schedule'
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.batch_message_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM platform_messages pm
|
||||||
|
WHERE pm.batch_id = s.batch_id AND pm.external_message_id = s.batch_message_id::VARCHAR(255)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify migration exists**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
|
||||||
|
# Expected: file exists
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
|
||||||
|
git commit -m "chore(db): add V011 migration — platform identity and platform_messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Создать shared enum PlatformKind
|
||||||
|
|
||||||
|
**Objective:** Ввести типизированное перечисление платформ для использования в коде и БД.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/GmRelay.Shared/Domain/PlatformKind.cs`
|
||||||
|
- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` (если нужен `[JsonStringEnumConverter]` или Dapper mapping)
|
||||||
|
|
||||||
|
**Step 1: Write failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public class PlatformKindTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Telegram_HasExpectedValue()
|
||||||
|
{
|
||||||
|
Assert.Equal("telegram", PlatformKind.Telegram.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Discord_HasExpectedValue()
|
||||||
|
{
|
||||||
|
Assert.Equal("discord", PlatformKind.Discord.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Equals_SameValue_ReturnsTrue()
|
||||||
|
{
|
||||||
|
Assert.Equal(PlatformKind.Telegram, PlatformKind.Telegram);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_KnownValue_ReturnsInstance()
|
||||||
|
{
|
||||||
|
var kind = PlatformKind.Parse("discord");
|
||||||
|
Assert.Equal(PlatformKind.Discord, kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
|
||||||
|
# Expected: FAIL — PlatformKind not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement PlatformKind**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// src/GmRelay.Shared/Domain/PlatformKind.cs
|
||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public sealed class PlatformKind : IEquatable<PlatformKind>
|
||||||
|
{
|
||||||
|
public static readonly PlatformKind Telegram = new("telegram");
|
||||||
|
public static readonly PlatformKind Discord = new("discord");
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
private PlatformKind(string value) => Value = value;
|
||||||
|
|
||||||
|
public static PlatformKind Parse(string value)
|
||||||
|
=> value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"telegram" => Telegram,
|
||||||
|
"discord" => Discord,
|
||||||
|
_ => throw new ArgumentException($"Unknown platform: {value}", nameof(value))
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool Equals(PlatformKind? other) => other is not null && Value == other.Value;
|
||||||
|
public override bool Equals(object? obj) => obj is PlatformKind other && Equals(other);
|
||||||
|
public override int GetHashCode() => Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
|
||||||
|
public override string ToString() => Value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
|
||||||
|
# Expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Shared/Domain/PlatformKind.cs tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
|
||||||
|
git commit -m "feat(shared): add PlatformKind value object"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Создать Dapper TypeHandler для PlatformKind
|
||||||
|
|
||||||
|
**Objective:** Обеспечить автоматическую сериализацию/десериализацию PlatformKind ↔ VARCHAR в PostgreSQL.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Program.cs` или точку регистрации Dapper handlers
|
||||||
|
|
||||||
|
**Step 1: Write failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Infrastructure.Database;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public class PlatformKindTypeHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PlatformKind_SetValue_ReturnsString()
|
||||||
|
{
|
||||||
|
var handler = new PlatformKindTypeHandler();
|
||||||
|
var param = new Npgsql.NpgsqlParameter();
|
||||||
|
handler.SetValue(param, PlatformKind.Discord);
|
||||||
|
Assert.Equal("discord", param.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlatformKind_Parse_ReturnsKind()
|
||||||
|
{
|
||||||
|
var handler = new PlatformKindTypeHandler();
|
||||||
|
var result = handler.Parse(typeof(PlatformKind), "telegram");
|
||||||
|
Assert.Equal(PlatformKind.Telegram, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
|
||||||
|
# Expected: FAIL — handler not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement handler**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Database;
|
||||||
|
|
||||||
|
public sealed class PlatformKindTypeHandler : SqlMapper.TypeHandler<PlatformKind>
|
||||||
|
{
|
||||||
|
public override void SetValue(IDbDataParameter parameter, PlatformKind value)
|
||||||
|
{
|
||||||
|
parameter.Value = value.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override PlatformKind Parse(object value)
|
||||||
|
{
|
||||||
|
var str = value as string ?? value.ToString() ?? string.Empty;
|
||||||
|
return PlatformKind.Parse(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Register handler**
|
||||||
|
|
||||||
|
В `Program.cs` (или в `DbMigrator` / startup extension) добавить:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SqlMapper.AddTypeHandler(new PlatformKindTypeHandler());
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
|
||||||
|
# Expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
|
||||||
|
git commit -m "feat(db): add Dapper PlatformKindTypeHandler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Создать down-migration V011__add_platform_identity.sql (rollback)
|
||||||
|
|
||||||
|
**Objective:** Обеспечить обратимость миграции для отката при ошибке.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql`
|
||||||
|
|
||||||
|
**Step 1: Write rollback script**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Rollback for V011: remove platform identity fields and platform_messages table
|
||||||
|
-- NOTE: This drops NEW data. Use only in emergency.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS platform_messages;
|
||||||
|
|
||||||
|
ALTER TABLE sessions DROP COLUMN IF EXISTS external_channel_id;
|
||||||
|
|
||||||
|
ALTER TABLE game_groups
|
||||||
|
DROP COLUMN IF EXISTS platform,
|
||||||
|
DROP COLUMN IF EXISTS external_group_id,
|
||||||
|
DROP COLUMN IF EXISTS external_channel_id;
|
||||||
|
|
||||||
|
ALTER TABLE players
|
||||||
|
DROP COLUMN IF EXISTS platform,
|
||||||
|
DROP COLUMN IF EXISTS external_user_id,
|
||||||
|
DROP COLUMN IF EXISTS external_username;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify file exists**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
|
||||||
|
# Expected: file exists
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
|
||||||
|
git commit -m "chore(db): add V011 rollback script"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Прогнать миграцию на integration-БД и проверить структуру
|
||||||
|
|
||||||
|
**Objective:** Убедиться, что миграция применяется без ошибок, backfill работает, индексы созданы.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: скрипт в `tests/integration/verify_v011.sql` или ручная проверка через psql
|
||||||
|
|
||||||
|
**Step 1: Run migrations**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/GmRelay.Bot
|
||||||
|
dotnet run --migrate-only # или через DbMigrator в Program.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify schema**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d players" | grep -E "platform|external"
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d game_groups" | grep -E "platform|external"
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d sessions" | grep external
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: все новые поля и таблица присутствуют.
|
||||||
|
|
||||||
|
**Step 3: Verify backfill**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM players WHERE external_user_id IS NOT NULL;"
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM game_groups WHERE external_group_id IS NOT NULL;"
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM platform_messages;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: counts > 0 для существующих данных.
|
||||||
|
|
||||||
|
**Step 4: Verify rollback works**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages" # should fail (table missing)
|
||||||
|
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql # re-apply
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit** (если добавили тесты)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/integration/verify_v011.sql
|
||||||
|
git commit -m "test(db): add V011 integration verification"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Обновить CreateSessionHandler — писать в platform identity при создании
|
||||||
|
|
||||||
|
**Objective:** При создании сессии заполнять новые `external_*` поля, не ломая Telegram flow.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
|
||||||
|
|
||||||
|
**Step 1: Write failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_CreatesPlayerWithPlatformIdentity()
|
||||||
|
{
|
||||||
|
// Arrange: mock NpgsqlDataSource + ITelegramBotClient
|
||||||
|
// Act: send /newsession
|
||||||
|
// Assert: INSERT into players includes external_user_id = telegram_id string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet test --filter "FullyQualifiedName~CreateSessionHandlerTests"
|
||||||
|
# Expected: FAIL — test or implementation missing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update handler SQL**
|
||||||
|
|
||||||
|
В `CreateSessionHandler.cs`, обновить INSERT для players:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||||
|
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
telegram_username = EXCLUDED.telegram_username,
|
||||||
|
external_username = EXCLUDED.external_username;
|
||||||
|
```
|
||||||
|
|
||||||
|
И INSERT для game_groups:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
||||||
|
VALUES (@ChatId, @ChatName, @GmId, 'telegram', @ChatId::VARCHAR(255))
|
||||||
|
RETURNING id;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run handler tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~CreateSession"
|
||||||
|
# Expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
|
||||||
|
git commit -m "feat(sessions): write platform identity fields on session creation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Обновить JoinSessionHandler — писать в platform identity
|
||||||
|
|
||||||
|
**Objective:** При join записывать `platform`, `external_user_id`, `external_username`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
||||||
|
|
||||||
|
**Step 1: Update SQL**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||||
|
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
telegram_username = EXCLUDED.telegram_username,
|
||||||
|
external_username = EXCLUDED.external_username
|
||||||
|
RETURNING id;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify no regression**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~JoinSession"
|
||||||
|
# Expected: PASS or no existing tests (manual smoke)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs
|
||||||
|
git commit -m "feat(sessions): write platform identity fields on join"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Обновить ListSessionsHandler — читать через platform identity
|
||||||
|
|
||||||
|
**Objective:** Запрос списка сессий должен работать через `external_group_id`, не ломая Telegram flow.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs`
|
||||||
|
|
||||||
|
**Step 1: Update SQL**
|
||||||
|
|
||||||
|
Заменить `g.telegram_chat_id = @ChatId` на `g.external_group_id = @ChatId::VARCHAR(255)`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, ...
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
WHERE g.external_group_id = @ChatId::VARCHAR(255)
|
||||||
|
AND s.status != @Cancelled
|
||||||
|
AND s.scheduled_at > NOW()
|
||||||
|
GROUP BY ...
|
||||||
|
ORDER BY s.scheduled_at ASC
|
||||||
|
```
|
||||||
|
|
||||||
|
Также обновить проверку прав:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.external_user_id = @TelegramUserId::VARCHAR(255)
|
||||||
|
) AS CanManage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ListSessions"
|
||||||
|
# Expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs
|
||||||
|
git commit -m "feat(sessions): read group via external_group_id in list handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Обновить CancelSessionHandler, LeaveSessionHandler, PromoteWaitlistedPlayerHandler
|
||||||
|
|
||||||
|
**Objective:** Аналогично Task 7-8: обновить SQL запросы на использование `external_user_id` и `external_group_id`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify:
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
|
||||||
|
|
||||||
|
**Step 1: Update SQL in each handler**
|
||||||
|
|
||||||
|
Replace patterns:
|
||||||
|
- `p.telegram_id = @TelegramUserId` → `p.external_user_id = @TelegramUserId::VARCHAR(255)`
|
||||||
|
- `g.telegram_chat_id = @ChatId` → `g.external_group_id = @ChatId::VARCHAR(255)`
|
||||||
|
- `s.group_id = g.id` stays
|
||||||
|
|
||||||
|
**Step 2: Run all session handler tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Session"
|
||||||
|
# Expected: all PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Features/Sessions/CreateSession/
|
||||||
|
git commit -m "feat(sessions): migrate session handlers to platform identity fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Обновить Reschedule и Reminder handlers
|
||||||
|
|
||||||
|
**Objective:** Обновить reschedule handlers и reminder sender на platform identity.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify:
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
|
||||||
|
- `src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs`
|
||||||
|
- `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
|
||||||
|
|
||||||
|
**Step 1: Update SQL**
|
||||||
|
|
||||||
|
Replace `telegram_id` / `telegram_chat_id` usages with `external_user_id` / `external_group_id` where это lookup.
|
||||||
|
|
||||||
|
**Step 2: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule"
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reminder"
|
||||||
|
# Expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ src/GmRelay.Bot/Features/Reminders/ src/GmRelay.Bot/Features/Notifications/
|
||||||
|
git commit -m "feat(reschedule,reminders): migrate to platform identity fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Обновить Web Dashboard queries (TelegramAuthService, GroupDetails, MiniApp)
|
||||||
|
|
||||||
|
**Objective:** Веб-компоненты, которые читают `telegram_id`, должны также поддерживать `external_user_id`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify:
|
||||||
|
- `src/GmRelay.Web/Services/TelegramAuthService.cs`
|
||||||
|
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
|
||||||
|
|
||||||
|
**Step 1: Update TelegramAuthService**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT p.id, p.display_name, p.telegram_username, p.external_user_id, p.external_username
|
||||||
|
FROM players p
|
||||||
|
WHERE p.telegram_id = @TelegramId
|
||||||
|
OR p.external_user_id = @TelegramId::VARCHAR(255)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update GroupDetails.razor queries**
|
||||||
|
|
||||||
|
Replace `telegram_chat_id` lookups with `external_group_id`.
|
||||||
|
|
||||||
|
**Step 3: Run web tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Web"
|
||||||
|
# Expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/GmRelay.Web/
|
||||||
|
git commit -m "feat(web): migrate web dashboard to platform identity fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: Создать документацию по platform identity в schema notes
|
||||||
|
|
||||||
|
**Objective:** Зафиксировать, что `telegram_*` deprecated, а новые поля — путь вперёд.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/db-schema/platform-migration-notes.md`
|
||||||
|
|
||||||
|
**Step 1: Write doc**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Platform Identity Migration Notes
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- `telegram_*` fields: DEPRECATED but kept for backward compatibility.
|
||||||
|
- `platform`, `external_*` fields: ACTIVE, used by all new handlers.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
| Table | Legacy | New Fields |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| players | telegram_id, telegram_username | platform, external_user_id, external_username |
|
||||||
|
| game_groups | telegram_chat_id | platform, external_group_id, external_channel_id |
|
||||||
|
| sessions | thread_id, batch_message_id | external_channel_id |
|
||||||
|
| platform_messages | — | NEW table tracking all batch messages |
|
||||||
|
|
||||||
|
## Backfill
|
||||||
|
V011 migration backfills `external_*` from existing `telegram_*` data.
|
||||||
|
|
||||||
|
## Future
|
||||||
|
V012+ (or separate migration) will eventually drop `telegram_*` once all code paths use `external_*`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/db-schema/platform-migration-notes.md
|
||||||
|
git commit -m "docs(db): add platform identity migration notes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 13: Final integration smoke test
|
||||||
|
|
||||||
|
**Objective:** Убедиться, что весь бот стартует, миграции проходят, и основные команды работают.
|
||||||
|
|
||||||
|
**Step 1: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
|
||||||
|
# Expected: 0 errors, 0 warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --no-build
|
||||||
|
# Expected: all PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Start bot and test /newsession + /listsessions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/GmRelay.Bot/GmRelay.Bot.csproj
|
||||||
|
# Manual smoke in Telegram: /newsession → verify session created, players.external_user_id populated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit final state**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status # should be clean
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] V011 migration applied successfully with backfill
|
||||||
|
- [ ] Rollback script tested and works
|
||||||
|
- [ ] PlatformKind + Dapper TypeHandler present and tested
|
||||||
|
- [ ] All session handlers use `external_*` fields
|
||||||
|
- [ ] Telegram commands (`/newsession`, `/listsessions`, join/leave) still work
|
||||||
|
- [ ] Web dashboard auth and group pages still work
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] No compiler warnings
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
usage: hermes mcp [-h] [--accept-hooks]
|
||||||
|
{serve,add,remove,rm,list,ls,test,configure,config,login}
|
||||||
|
...
|
||||||
|
hermes mcp: error: argument mcp_action: invalid choice: 'call' (choose from 'serve', 'add', 'remove', 'rm', 'list', 'ls', 'test', 'configure', 'config', 'login')
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# ADR 002: Platform-Neutral Batch Rendering
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
**Accepted** — implemented in v1.10.0 (PR #42).
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
|
|
||||||
|
|
||||||
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
|
|
||||||
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
|
|
||||||
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Разделить рендеринг на две стадии:
|
|
||||||
|
|
||||||
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
|
|
||||||
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
|
|
||||||
|
|
||||||
```
|
|
||||||
Domain DTOs
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
SessionBatchViewBuilder (Shared)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
SessionBatchViewModel (platform-neutral)
|
|
||||||
│
|
|
||||||
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
|
|
||||||
│
|
|
||||||
└──► DiscordSessionBatchRenderer ──► (issue #26)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Изменённые компоненты
|
|
||||||
|
|
||||||
| Компонент | Было | Стало |
|
|
||||||
|---|---|---|
|
|
||||||
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
|
|
||||||
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
|
|
||||||
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
|
|
||||||
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
|
|
||||||
| `DiscordSessionBatchRenderer` | — | `GmRelay.Shared.Rendering` (stub) |
|
|
||||||
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
|
|
||||||
- Можно добавить `DiscordSessionBatchRenderer` без изменений в `Shared`.
|
|
||||||
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
|
|
||||||
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
|
|
||||||
|
|
||||||
### Negative
|
|
||||||
|
|
||||||
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
|
|
||||||
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- Issue #22 — этот рефакторинг.
|
|
||||||
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
|
|
||||||
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
|
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Landing;
|
namespace GmRelay.Bot.Tests.Features.Landing;
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
|
|
||||||
private void RenderBatch()
|
private void RenderBatch()
|
||||||
{
|
{
|
||||||
var view = SessionBatchViewBuilder.Build(
|
var viewModel = SessionBatchViewBuilder.Build(
|
||||||
Title,
|
Title,
|
||||||
sessions
|
sessions
|
||||||
.Select(session => new SessionBatchDto(
|
.Select(session => new SessionBatchDto(
|
||||||
@@ -327,7 +327,8 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
participant.TelegramUsername,
|
participant.TelegramUsername,
|
||||||
participant.RegistrationStatus))
|
participant.RegistrationStatus))
|
||||||
.ToList());
|
.ToList());
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(viewModel);
|
||||||
|
|
||||||
if (Messenger.HasPublishedMessage)
|
if (Messenger.HasPublishedMessage)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace GmRelay.Bot.Tests.Rendering;
|
|||||||
public sealed class TelegramSessionBatchRendererTests
|
public sealed class TelegramSessionBatchRendererTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_ShouldProduceCorrectHtmlAndButtons()
|
public void Render_ShouldProduceSameTextAsOldRenderer()
|
||||||
{
|
{
|
||||||
var firstSessionId = Guid.NewGuid();
|
var firstSessionId = Guid.NewGuid();
|
||||||
var secondSessionId = Guid.NewGuid();
|
var secondSessionId = Guid.NewGuid();
|
||||||
@@ -26,24 +26,49 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Old renderer output
|
||||||
|
var (oldText, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||||
|
|
||||||
|
// New pipeline output
|
||||||
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
var (newText, newMarkup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
Assert.Contains("Campaign", text);
|
Assert.Equal(oldText, newText);
|
||||||
Assert.Contains("@alice", text);
|
}
|
||||||
Assert.Contains("Charlie", text);
|
|
||||||
Assert.Contains("Bob", text);
|
|
||||||
Assert.Contains("Сессия отменена", text);
|
|
||||||
|
|
||||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
[Fact]
|
||||||
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
|
public void Render_ShouldProduceSameButtonsAsOldRenderer()
|
||||||
|
{
|
||||||
|
var firstSessionId = Guid.NewGuid();
|
||||||
|
var secondSessionId = Guid.NewGuid();
|
||||||
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
|
|
||||||
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{firstSessionId}");
|
var sessions = new[]
|
||||||
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{firstSessionId}");
|
{
|
||||||
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{secondSessionId}");
|
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
||||||
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{secondSessionId}");
|
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
||||||
Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("cancel") == true);
|
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
||||||
Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("reschedule") == true);
|
};
|
||||||
|
var participants = new[]
|
||||||
|
{
|
||||||
|
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||||
|
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||||
|
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||||
|
};
|
||||||
|
|
||||||
|
var (_, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||||
|
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||||
|
var (_, newMarkup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
var oldButtons = oldMarkup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
var newButtons = newMarkup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(oldButtons.Count, newButtons.Count);
|
||||||
|
for (int i = 0; i < oldButtons.Count; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(oldButtons[i].CallbackData, newButtons[i].CallbackData);
|
||||||
|
Assert.Equal(oldButtons[i].Text, newButtons[i].Text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user