diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index bb50c0b..217ebab 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.16.0 + VERSION: 2.0.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index cd23ad3..660744d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.16.0 + 2.0.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index c33c443..6492075 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.16.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.0.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.16.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.0.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index b49ed5a..9beea10 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -77,11 +77,14 @@ public sealed class CreateSessionHandler( { await connection.ExecuteAsync( """ - INSERT INTO players (telegram_id, display_name, telegram_username) - VALUES (@TgId, @Name, @Username) + INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) + VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username) ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, - telegram_username = EXCLUDED.telegram_username; + telegram_username = EXCLUDED.telegram_username, + platform = COALESCE(players.platform, 'Telegram'), + external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT), + external_username = COALESCE(players.external_username, EXCLUDED.telegram_username); """, new { TgId = gmId, Name = gmName, Username = gmUsername }, transaction); @@ -94,10 +97,10 @@ public sealed class CreateSessionHandler( FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = g.id - AND p.telegram_id = @GmId + AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT ) AS CanManage FROM game_groups g - WHERE g.telegram_chat_id = @ChatId + WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT """, new { ChatId = chatId, GmId = gmId }, transaction); @@ -107,8 +110,8 @@ public sealed class CreateSessionHandler( { groupId = await connection.ExecuteScalarAsync( """ - INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) - VALUES (@ChatId, @ChatName, @GmId) + INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id) + VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT) RETURNING id; """, new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, @@ -119,7 +122,7 @@ public sealed class CreateSessionHandler( INSERT INTO group_managers (group_id, player_id, role) SELECT @GroupId, p.id, @OwnerRole FROM players p - WHERE p.telegram_id = @GmId + WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT ON CONFLICT (group_id, player_id) DO NOTHING """, new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }, diff --git a/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql b/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql index 693115b..87354a0 100644 --- a/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql +++ b/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql @@ -47,7 +47,7 @@ BEGIN SELECT pt.player_id, p.display_name, - p.telegram_username, + COALESCE(p.external_username, p.telegram_username) AS telegram_username, pt.total_sessions, pt.confirmed_count, pt.declined_count, diff --git a/src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql b/src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql new file mode 100644 index 0000000..5ef64ac --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql @@ -0,0 +1,119 @@ +-- ============================================================= +-- V016: Add platform identity columns and platform_messages table +-- ============================================================= +-- Scope: Prepare schema for multi-platform support (Discord, etc). +-- Legacy telegram_* columns are retained for backward compatibility. +-- ============================================================= + +-- ── Players: platform-agnostic identity ───────────────────────── +ALTER TABLE players + ADD COLUMN platform VARCHAR(50), + ADD COLUMN external_user_id VARCHAR(255), + ADD COLUMN external_username VARCHAR(255); + +CREATE UNIQUE INDEX ix_players_platform_external_user_id + ON players (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL; + +-- ── Game groups: platform-agnostic identity ───────────────────── +ALTER TABLE game_groups + ADD COLUMN platform VARCHAR(50), + ADD COLUMN external_group_id VARCHAR(255), + ADD COLUMN external_channel_id VARCHAR(255); + +CREATE UNIQUE INDEX ix_game_groups_platform_external_group_id + ON game_groups (platform, external_group_id) + WHERE platform IS NOT NULL AND external_group_id IS NOT NULL; + +-- ── Backfill existing Telegram data ───────────────────────────── +UPDATE players + SET platform = 'Telegram', + external_user_id = telegram_id::TEXT, + external_username = telegram_username + WHERE platform IS NULL; + +UPDATE game_groups + SET platform = 'Telegram', + external_group_id = telegram_chat_id::TEXT + WHERE platform IS NULL; + +-- ── Platform messages: store per-platform message references ──── +CREATE TABLE platform_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform VARCHAR(50) NOT NULL, + group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE, + batch_id UUID, + 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, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX ix_platform_messages_group_id ON platform_messages(group_id); +CREATE INDEX ix_platform_messages_batch_id ON platform_messages(batch_id); +CREATE INDEX ix_platform_messages_session_id ON platform_messages(session_id); +CREATE INDEX ix_platform_messages_platform_message + ON platform_messages (platform, external_message_id); + +-- ── Recreate attendance stats function for new columns (prod back-compat) ── +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, + COALESCE(p.external_username, p.telegram_username) AS 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; diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index f91bc15..cdc0a94 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs new file mode 100644 index 0000000..c386ca1 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs @@ -0,0 +1,98 @@ +namespace GmRelay.Bot.Tests.Infrastructure.Database; + +public sealed class PlatformIdentityMigrationTests +{ + [Fact] + public async Task MigrationV016_ShouldAddPlatformIdentityColumns() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql"); + + Assert.Contains("players", migration, StringComparison.Ordinal); + Assert.Contains("platform", migration, StringComparison.Ordinal); + Assert.Contains("external_user_id", migration, StringComparison.Ordinal); + Assert.Contains("external_username", migration, StringComparison.Ordinal); + + Assert.Contains("game_groups", migration, StringComparison.Ordinal); + Assert.Contains("external_group_id", migration, StringComparison.Ordinal); + Assert.Contains("external_channel_id", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV016_ShouldCreatePlatformMessagesTable() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql"); + + Assert.Contains("CREATE TABLE platform_messages", migration, StringComparison.Ordinal); + Assert.Contains("platform", migration, StringComparison.Ordinal); + Assert.Contains("group_id", migration, StringComparison.Ordinal); + Assert.Contains("batch_id", migration, StringComparison.Ordinal); + Assert.Contains("session_id", migration, StringComparison.Ordinal); + Assert.Contains("external_thread_id", migration, StringComparison.Ordinal); + Assert.Contains("external_message_id", migration, StringComparison.Ordinal); + Assert.Contains("purpose", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV016_ShouldBackfillExistingTelegramData() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql"); + + Assert.Contains("UPDATE players", migration, StringComparison.Ordinal); + Assert.Contains("UPDATE game_groups", migration, StringComparison.Ordinal); + Assert.Contains("'Telegram'", migration, StringComparison.Ordinal); + Assert.Contains("telegram_id", migration, StringComparison.Ordinal); + Assert.Contains("telegram_chat_id", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV016_ShouldNotDropLegacyTelegramColumns() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V016__add_platform_identity.sql"); + + Assert.DoesNotContain("DROP COLUMN telegram_id", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DROP COLUMN telegram_chat_id", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DROP COLUMN telegram_username", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DROP COLUMN gm_telegram_id", migration, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Code_ShouldQueryPlayersUsingExternalUserIdFallback() + { + var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); + + Assert.Contains("external_user_id", createHandler, StringComparison.Ordinal); + } + + [Fact] + public async Task Code_ShouldQueryGroupsUsingExternalGroupIdFallback() + { + var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); + + Assert.Contains("external_group_id", createHandler, StringComparison.Ordinal); + } + + [Fact] + public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername() + { + var statsMigration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql"); + + Assert.Contains("external_username", statsMigration, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +}