refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer

- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Все вызовы обновлены на новую цепочку ViewBuilder → TelegramRenderer
This commit is contained in:
root
2026-05-06 07:57:23 +00:00
parent 5dee2d87f5
commit 52b7b3e0fd
25 changed files with 1268 additions and 156 deletions
+752
View File
@@ -0,0 +1,752 @@
# Issue #23: Добавить platform identity и platform_messages в БД
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Расширить PostgreSQL-схему нейтральными platform-полями и таблицей platform_messages, чтобы Discord-адаптер мог работать без чтения `telegram_*` legacy-полей. Сохранить обратную совместимость с Telegram-сценариями.
**Architecture:**
- Добавляем generic `platform` enum-like поля (`VARCHAR(50)`) + `external_*` ID-поля рядом с legacy `telegram_*`.
- Создаём таблицу `platform_messages` для отслеживания batch-сообщений на любой платформе.
- Миграция обратима: `telegram_*` НЕ удаляются, новые поля nullable/с дефолтами.
- После миграции обновляем SQL-запросы в handlers (Task 8-12) чтобы сначала писать и читать через новые поля.
**Tech Stack:** PostgreSQL 16+, DbUp, Dapper, .NET 10, Npgsql.
**Milestone:** Версия 2.0 — Discord Bot MVP
---
## Task 1: Написать миграцию V011__add_platform_identity.sql
**Objective:** Создать обратимую миграцию, добавляющую platform-поля и таблицу platform_messages.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql`
**Step 1: Write failing test**
```bash
# Проверяем, что миграция ещё не существует
ls src/GmRelay.Bot/Migrations/V011__*.sql
# Expected: FAIL — file not found
```
**Step 2: Write migration script**
```sql
-- =============================================================
-- V011: Add platform identity fields for multi-platform support
-- Telegram remains default platform; old telegram_* fields kept.
-- =============================================================
-- 1) players: add platform identity columns
ALTER TABLE players
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_user_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255);
-- Populate external_user_id/external_username from existing telegram data
UPDATE players
SET external_user_id = telegram_id::VARCHAR(255),
external_username = telegram_username
WHERE external_user_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_players_platform_external_user
ON players (platform, external_user_id);
-- 2) game_groups: add platform identity columns
ALTER TABLE game_groups
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_group_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE game_groups
SET external_group_id = telegram_chat_id::VARCHAR(255)
WHERE external_group_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_game_groups_platform_external_group
ON game_groups (platform, external_group_id);
-- 3) sessions: add external_channel_id for platform-neutral thread/channel tracking
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE sessions
SET external_channel_id = thread_id::VARCHAR(255)
WHERE external_channel_id IS NULL AND thread_id IS NOT NULL;
-- 4) platform_messages: new table for tracking messages across platforms
CREATE TABLE IF NOT EXISTS platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID NOT NULL,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL DEFAULT 'schedule'
CHECK (purpose IN ('schedule','reminder','confirmation','reschedule_vote','direct_notification')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_platform_messages_group_batch
ON platform_messages (group_id, batch_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_session
ON platform_messages (session_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_purpose
ON platform_messages (purpose);
-- 5) Backfill platform_messages from existing sessions.batch_message_id data
INSERT INTO platform_messages (platform, group_id, batch_id, session_id, external_channel_id, external_message_id, purpose)
SELECT 'telegram',
s.group_id,
s.batch_id,
s.id,
NULL,
s.batch_message_id::VARCHAR(255),
'schedule'
FROM sessions s
WHERE s.batch_message_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM platform_messages pm
WHERE pm.batch_id = s.batch_id AND pm.external_message_id = s.batch_message_id::VARCHAR(255)
);
```
**Step 3: Verify migration exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
# Expected: file exists
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
git commit -m "chore(db): add V011 migration — platform identity and platform_messages"
```
---
## Task 2: Создать shared enum PlatformKind
**Objective:** Ввести типизированное перечисление платформ для использования в коде и БД.
**Files:**
- Create: `src/GmRelay.Shared/Domain/PlatformKind.cs`
- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` (если нужен `[JsonStringEnumConverter]` или Dapper mapping)
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
using GmRelay.Shared.Domain;
public class PlatformKindTests
{
[Fact]
public void Telegram_HasExpectedValue()
{
Assert.Equal("telegram", PlatformKind.Telegram.Value);
}
[Fact]
public void Discord_HasExpectedValue()
{
Assert.Equal("discord", PlatformKind.Discord.Value);
}
[Fact]
public void Equals_SameValue_ReturnsTrue()
{
Assert.Equal(PlatformKind.Telegram, PlatformKind.Telegram);
}
[Fact]
public void Parse_KnownValue_ReturnsInstance()
{
var kind = PlatformKind.Parse("discord");
Assert.Equal(PlatformKind.Discord, kind);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: FAIL — PlatformKind not found
```
**Step 2: Implement PlatformKind**
```csharp
// src/GmRelay.Shared/Domain/PlatformKind.cs
namespace GmRelay.Shared.Domain;
public sealed class PlatformKind : IEquatable<PlatformKind>
{
public static readonly PlatformKind Telegram = new("telegram");
public static readonly PlatformKind Discord = new("discord");
public string Value { get; }
private PlatformKind(string value) => Value = value;
public static PlatformKind Parse(string value)
=> value.ToLowerInvariant() switch
{
"telegram" => Telegram,
"discord" => Discord,
_ => throw new ArgumentException($"Unknown platform: {value}", nameof(value))
};
public bool Equals(PlatformKind? other) => other is not null && Value == other.Value;
public override bool Equals(object? obj) => obj is PlatformKind other && Equals(other);
public override int GetHashCode() => Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
public override string ToString() => Value;
}
```
**Step 3: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Shared/Domain/PlatformKind.cs tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
git commit -m "feat(shared): add PlatformKind value object"
```
---
## Task 3: Создать Dapper TypeHandler для PlatformKind
**Objective:** Обеспечить автоматическую сериализацию/десериализацию PlatformKind ↔ VARCHAR в PostgreSQL.
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs`
- Modify: `src/GmRelay.Bot/Program.cs` или точку регистрации Dapper handlers
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
using Dapper;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Domain;
public class PlatformKindTypeHandlerTests
{
[Fact]
public void PlatformKind_SetValue_ReturnsString()
{
var handler = new PlatformKindTypeHandler();
var param = new Npgsql.NpgsqlParameter();
handler.SetValue(param, PlatformKind.Discord);
Assert.Equal("discord", param.Value);
}
[Fact]
public void PlatformKind_Parse_ReturnsKind()
{
var handler = new PlatformKindTypeHandler();
var result = handler.Parse(typeof(PlatformKind), "telegram");
Assert.Equal(PlatformKind.Telegram, result);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: FAIL — handler not found
```
**Step 2: Implement handler**
```csharp
// src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs
using Dapper;
using GmRelay.Shared.Domain;
using System.Data;
namespace GmRelay.Bot.Infrastructure.Database;
public sealed class PlatformKindTypeHandler : SqlMapper.TypeHandler<PlatformKind>
{
public override void SetValue(IDbDataParameter parameter, PlatformKind value)
{
parameter.Value = value.Value;
}
public override PlatformKind Parse(object value)
{
var str = value as string ?? value.ToString() ?? string.Empty;
return PlatformKind.Parse(str);
}
}
```
**Step 3: Register handler**
В `Program.cs` (или в `DbMigrator` / startup extension) добавить:
```csharp
SqlMapper.AddTypeHandler(new PlatformKindTypeHandler());
```
**Step 4: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: PASS
```
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
git commit -m "feat(db): add Dapper PlatformKindTypeHandler"
```
---
## Task 4: Создать down-migration V011__add_platform_identity.sql (rollback)
**Objective:** Обеспечить обратимость миграции для отката при ошибке.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql`
**Step 1: Write rollback script**
```sql
-- Rollback for V011: remove platform identity fields and platform_messages table
-- NOTE: This drops NEW data. Use only in emergency.
DROP TABLE IF EXISTS platform_messages;
ALTER TABLE sessions DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE game_groups
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_group_id,
DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE players
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_user_id,
DROP COLUMN IF EXISTS external_username;
```
**Step 2: Verify file exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
# Expected: file exists
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
git commit -m "chore(db): add V011 rollback script"
```
---
## Task 5: Прогнать миграцию на integration-БД и проверить структуру
**Objective:** Убедиться, что миграция применяется без ошибок, backfill работает, индексы созданы.
**Files:**
- Test: скрипт в `tests/integration/verify_v011.sql` или ручная проверка через psql
**Step 1: Run migrations**
```bash
cd src/GmRelay.Bot
dotnet run --migrate-only # или через DbMigrator в Program.cs
```
**Step 2: Verify schema**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d players" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d game_groups" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d sessions" | grep external
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages"
```
Expected: все новые поля и таблица присутствуют.
**Step 3: Verify backfill**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM players WHERE external_user_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM game_groups WHERE external_group_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM platform_messages;"
```
Expected: counts > 0 для существующих данных.
**Step 4: Verify rollback works**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages" # should fail (table missing)
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql # re-apply
```
**Step 5: Commit** (если добавили тесты)
```bash
git add tests/integration/verify_v011.sql
git commit -m "test(db): add V011 integration verification"
```
---
## Task 6: Обновить CreateSessionHandler — писать в platform identity при создании
**Objective:** При создании сессии заполнять новые `external_*` поля, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
[Fact]
public async Task HandleAsync_CreatesPlayerWithPlatformIdentity()
{
// Arrange: mock NpgsqlDataSource + ITelegramBotClient
// Act: send /newsession
// Assert: INSERT into players includes external_user_id = telegram_id string
}
```
Run:
```bash
dotnet test --filter "FullyQualifiedName~CreateSessionHandlerTests"
# Expected: FAIL — test or implementation missing
```
**Step 2: Update handler SQL**
В `CreateSessionHandler.cs`, обновить INSERT для players:
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username;
```
И INSERT для game_groups:
```sql
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'telegram', @ChatId::VARCHAR(255))
RETURNING id;
```
**Step 3: Run handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~CreateSession"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on session creation"
```
---
## Task 7: Обновить JoinSessionHandler — писать в platform identity
**Objective:** При join записывать `platform`, `external_user_id`, `external_username`.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
**Step 1: Update SQL**
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username
RETURNING id;
```
**Step 2: Verify no regression**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~JoinSession"
# Expected: PASS or no existing tests (manual smoke)
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on join"
```
---
## Task 8: Обновить ListSessionsHandler — читать через platform identity
**Objective:** Запрос списка сессий должен работать через `external_group_id`, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs`
**Step 1: Update SQL**
Заменить `g.telegram_chat_id = @ChatId` на `g.external_group_id = @ChatId::VARCHAR(255)`.
```sql
SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, ...
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.external_group_id = @ChatId::VARCHAR(255)
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY ...
ORDER BY s.scheduled_at ASC
```
Также обновить проверку прав:
```sql
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.external_user_id = @TelegramUserId::VARCHAR(255)
) AS CanManage
```
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ListSessions"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs
git commit -m "feat(sessions): read group via external_group_id in list handler"
```
---
## Task 9: Обновить CancelSessionHandler, LeaveSessionHandler, PromoteWaitlistedPlayerHandler
**Objective:** Аналогично Task 7-8: обновить SQL запросы на использование `external_user_id` и `external_group_id`.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
**Step 1: Update SQL in each handler**
Replace patterns:
- `p.telegram_id = @TelegramUserId``p.external_user_id = @TelegramUserId::VARCHAR(255)`
- `g.telegram_chat_id = @ChatId``g.external_group_id = @ChatId::VARCHAR(255)`
- `s.group_id = g.id` stays
**Step 2: Run all session handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Session"
# Expected: all PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/
git commit -m "feat(sessions): migrate session handlers to platform identity fields"
```
---
## Task 10: Обновить Reschedule и Reminder handlers
**Objective:** Обновить reschedule handlers и reminder sender на platform identity.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
- `src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs`
- `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
**Step 1: Update SQL**
Replace `telegram_id` / `telegram_chat_id` usages with `external_user_id` / `external_group_id` where это lookup.
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule"
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reminder"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ src/GmRelay.Bot/Features/Reminders/ src/GmRelay.Bot/Features/Notifications/
git commit -m "feat(reschedule,reminders): migrate to platform identity fields"
```
---
## Task 11: Обновить Web Dashboard queries (TelegramAuthService, GroupDetails, MiniApp)
**Objective:** Веб-компоненты, которые читают `telegram_id`, должны также поддерживать `external_user_id`.
**Files:**
- Modify:
- `src/GmRelay.Web/Services/TelegramAuthService.cs`
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1: Update TelegramAuthService**
```sql
SELECT p.id, p.display_name, p.telegram_username, p.external_user_id, p.external_username
FROM players p
WHERE p.telegram_id = @TelegramId
OR p.external_user_id = @TelegramId::VARCHAR(255)
```
**Step 2: Update GroupDetails.razor queries**
Replace `telegram_chat_id` lookups with `external_group_id`.
**Step 3: Run web tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Web"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/
git commit -m "feat(web): migrate web dashboard to platform identity fields"
```
---
## Task 12: Создать документацию по platform identity в schema notes
**Objective:** Зафиксировать, что `telegram_*` deprecated, а новые поля — путь вперёд.
**Files:**
- Create: `docs/db-schema/platform-migration-notes.md`
**Step 1: Write doc**
```markdown
# Platform Identity Migration Notes
## Status
- `telegram_*` fields: DEPRECATED but kept for backward compatibility.
- `platform`, `external_*` fields: ACTIVE, used by all new handlers.
## Tables
| Table | Legacy | New Fields |
|-------|--------|-----------|
| players | telegram_id, telegram_username | platform, external_user_id, external_username |
| game_groups | telegram_chat_id | platform, external_group_id, external_channel_id |
| sessions | thread_id, batch_message_id | external_channel_id |
| platform_messages | — | NEW table tracking all batch messages |
## Backfill
V011 migration backfills `external_*` from existing `telegram_*` data.
## Future
V012+ (or separate migration) will eventually drop `telegram_*` once all code paths use `external_*`.
```
**Step 2: Commit**
```bash
git add docs/db-schema/platform-migration-notes.md
git commit -m "docs(db): add platform identity migration notes"
```
---
## Task 13: Final integration smoke test
**Objective:** Убедиться, что весь бот стартует, миграции проходят, и основные команды работают.
**Step 1: Build**
```bash
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
# Expected: 0 errors, 0 warnings
```
**Step 2: Run all tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --no-build
# Expected: all PASS
```
**Step 3: Start bot and test /newsession + /listsessions**
```bash
dotnet run --project src/GmRelay.Bot/GmRelay.Bot.csproj
# Manual smoke in Telegram: /newsession → verify session created, players.external_user_id populated
```
**Step 4: Commit final state**
```bash
git status # should be clean
git log --oneline -5
```
---
## Verification Checklist
- [ ] V011 migration applied successfully with backfill
- [ ] Rollback script tested and works
- [ ] PlatformKind + Dapper TypeHandler present and tested
- [ ] All session handlers use `external_*` fields
- [ ] Telegram commands (`/newsession`, `/listsessions`, join/leave) still work
- [ ] Web dashboard auth and group pages still work
- [ ] All tests pass
- [ ] No compiler warnings