using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using GmRelay.Web.Services; namespace GmRelay.Web.Services; public sealed record WebGameGroup( Guid Id, long TelegramChatId, string? ExternalGroupId, string Name, string? Platform, string ManagerRole = GroupManagerRoleExtensions.OwnerValue) { public long GmTelegramId { get; init; } public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId) : this(id, telegramChatId, null, name, null) { GmTelegramId = gmTelegramId; } } public sealed record WebGroupManager( long TelegramId, string? ExternalUserId, string DisplayName, string? TelegramUsername, string? ExternalUsername, string Role, DateTime AddedAt) { public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt) : this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { } } public sealed record WebGroupManagement( WebGameGroup Group, IReadOnlyList Managers, bool CurrentUserIsOwner); public sealed record WebSession( Guid Id, Guid GroupId, string Title, DateTime ScheduledAt, string Status, string JoinLink, Guid BatchId, int? BatchMessageId, long TelegramChatId, int? MaxPlayers, int ActivePlayerCount, int WaitlistedPlayerCount, string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue, int? ThreadId = null); public sealed record WebParticipant( Guid Id, long TelegramId, string? ExternalUserId, string DisplayName, string? TelegramUsername, string? ExternalUsername, string RsvpStatus, string RegistrationStatus, bool IsGm, DateTime? RespondedAt); internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName); internal sealed record WebBatchInfo( Guid BatchId, Guid GroupId, string Title, string JoinLink, long TelegramChatId, int? BatchMessageId, int? ThreadId, string NotificationMode); internal sealed record WebBatchSessionRow( Guid Id, Guid GroupId, string Title, string JoinLink, DateTime ScheduledAt, string Status, int? MaxPlayers, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode, bool TopicCreatedByBot = false); internal sealed record WebTemplateGroupDto(long TelegramChatId); public sealed class SessionService( NpgsqlDataSource dataSource, ITelegramBotClient bot, ILogger logger) : ISessionStore { public async Task> GetGroupsForUserAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); if (effectiveId is null) return []; return (await conn.QueryAsync( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, g.external_group_id AS ExternalGroupId, g.name, g.platform AS Platform, gm.role AS ManagerRole FROM group_managers gm JOIN game_groups g ON g.id = gm.group_id WHERE gm.player_id = @PlayerId ORDER BY g.name """, new { PlayerId = effectiveId.Value })).ToList(); } public async Task GetGroupAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, g.external_group_id AS ExternalGroupId, g.name, g.platform AS Platform, @OwnerRole AS ManagerRole FROM game_groups g WHERE g.id = @GroupId """, new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); if (effectiveId is null) return false; return await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM group_managers WHERE group_id = @GroupId AND player_id = @PlayerId ) """, new { GroupId = groupId, PlayerId = effectiveId.Value }); } public async Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); if (effectiveId is null) return false; return await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM group_managers WHERE group_id = @GroupId AND player_id = @PlayerId AND role = @OwnerRole ) """, new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task> GetGroupManagersAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT COALESCE(p.telegram_id, 0) AS TelegramId, COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, gm.role AS Role, gm.created_at AS AddedAt FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, gm.created_at, p.display_name """, new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList(); } public async Task> GetGroupAttendanceStatsAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, 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.external_username, p.telegram_username ORDER BY AttendanceRate DESC, ConfirmedCount DESC """, new { GroupId = groupId })).ToList(); } public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( """ INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value) VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue) """, new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue }); } public async Task> GetSessionHistoryAsync(Guid sessionId) { await using var conn = await dataSource.OpenConnectionAsync(); var entries = await conn.QueryAsync( """ SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName, change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt FROM session_audit_log WHERE session_id = @SessionId ORDER BY changed_at DESC """, new { SessionId = sessionId }); return entries.ToList(); } public async Task AddGroupCoGmAsync( Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var ownerPlayerId = await _ResolveEffectivePlayerIdAsync(conn, ownerPlatform, ownerExternalUserId); if (ownerPlayerId is null) throw new InvalidOperationException("Owner player not found."); var coGmPlayerId = await _UpsertPlayerAndGetIdAsync(conn, coGmPlatform, coGmExternalUserId, displayName, externalUsername, transaction); await conn.ExecuteAsync( """ INSERT INTO group_managers (group_id, player_id, role, added_by_player_id) VALUES (@GroupId, @CoGmPlayerId, @CoGmRole, @OwnerPlayerId) ON CONFLICT (group_id, player_id) DO UPDATE SET role = CASE WHEN group_managers.role = @OwnerRole THEN group_managers.role ELSE EXCLUDED.role END, added_by_player_id = EXCLUDED.added_by_player_id """, new { GroupId = groupId, OwnerPlayerId = ownerPlayerId.Value, CoGmPlayerId = coGmPlayerId, OwnerRole = GroupManagerRoleExtensions.OwnerValue, CoGmRole = GroupManagerRoleExtensions.CoGmValue }, transaction); await transaction.CommitAsync(); } public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); var coGmPlayerId = await _ResolveEffectivePlayerIdAsync(conn, coGmPlatform, coGmExternalUserId); if (coGmPlayerId is null) return; await conn.ExecuteAsync( """ DELETE FROM group_managers WHERE group_id = @GroupId AND player_id = @PlayerId AND role = @CoGmRole """, new { GroupId = groupId, PlayerId = coGmPlayerId.Value, CoGmRole = GroupManagerRoleExtensions.CoGmValue }); } public async Task> GetUpcomingSessionsAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( @"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, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, s.notification_mode AS NotificationMode, s.thread_id AS ThreadId FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Active ) active_counts ON true LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Waitlisted ) waitlist_counts ON true WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours' ORDER BY s.scheduled_at", new { GroupId = groupId, Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted })).ToList(); } public async Task GetSessionAsync(Guid sessionId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( @"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, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, s.notification_mode AS NotificationMode, s.thread_id AS ThreadId FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Active ) active_counts ON true LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Waitlisted ) waitlist_counts ON true WHERE s.id = @SessionId", new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted }); } public async Task GetBatchAsync(Guid batchId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( """ SELECT s.batch_id AS Id, s.group_id AS GroupId, (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, MIN(s.scheduled_at) AS FirstScheduledAt, MAX(s.scheduled_at) AS LastScheduledAt, COUNT(*)::int AS SessionCount, (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode FROM sessions s WHERE s.batch_id = @BatchId GROUP BY s.batch_id, s.group_id """, new { BatchId = batchId }); } public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var oldSession = await conn.QuerySingleOrDefaultAsync( @"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, s.thread_id AS ThreadId FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @Id AND s.group_id = @GroupId", new { Id = sessionId, GroupId = groupId }, transaction); if (oldSession is null) { throw new SessionAccessDeniedException(sessionId, "0"); } var updatedRows = await conn.ExecuteAsync( @"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, max_players = @MaxPlayers, one_hour_reminder_processed_at = CASE WHEN scheduled_at <> @ScheduledAt THEN NULL ELSE one_hour_reminder_processed_at END, updated_at = now() WHERE id = @Id AND group_id = @GroupId", new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink, MaxPlayers = maxPlayers }, transaction); if (updatedRows == 0) { throw new SessionAccessDeniedException(sessionId, "0"); } await conn.ExecuteAsync( "UPDATE sessions SET title = @Title WHERE batch_id = @BatchId", new { Title = title, BatchId = oldSession.BatchId }, transaction); await transaction.CommitAsync(); var timeChanged = oldSession.ScheduledAt != scheduledAt; var notification = $"🔄 Мастер обновил игру!\n\n" + $"📌 {System.Net.WebUtility.HtmlEncode(title)}\n" + $"📅 Время: {scheduledAt.FormatMoscow()} (МСК)" + (timeChanged ? " (изменено)" : "") + "\n" + $"👥 Мест: {(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}"; await bot.SendMessage( chatId: oldSession.TelegramChatId, messageThreadId: oldSession.ThreadId, text: notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode); if (mode.ShouldSendDirectMessages()) { var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId); await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId); } if (oldSession.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title); } } public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var session = await conn.QuerySingleOrDefaultAsync( @"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, s.thread_id AS ThreadId 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 activeParticipants = await conn.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active """, new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, transaction); var waitlistedParticipants = await conn.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Waitlisted """, new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, transaction); if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants)) { throw new InvalidOperationException(waitlistedParticipants == 0 ? "Лист ожидания пуст." : "Нет свободных мест для повышения игрока."); } var promoted = await conn.QuerySingleAsync( """ 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); 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( chatId: session.TelegramChatId, messageThreadId: session.ThreadId, text: $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{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); } } public async Task> GetSessionParticipantsAsync(Guid sessionId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT sp.id AS Id, COALESCE(p.telegram_id, 0) AS TelegramId, COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, 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( @"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, s.thread_id AS ThreadId 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( """ 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( """ 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( chatId: session.TelegramChatId, messageThreadId: session.ThreadId, text: $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); if (promoted is not null) { await bot.SendMessage( chatId: session.TelegramChatId, messageThreadId: session.ThreadId, text: $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{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) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { throw new SessionAccessDeniedException(batchId, "0"); } var updatedRows = await conn.ExecuteAsync( """ UPDATE sessions SET title = @Title, join_link = @JoinLink, updated_at = now() WHERE batch_id = @BatchId AND group_id = @GroupId """, new { BatchId = batchId, GroupId = groupId, Title = title, JoinLink = joinLink }, transaction); if (updatedRows == 0) { throw new SessionAccessDeniedException(batchId, "0"); } await transaction.CommitAsync(); if (batch.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title); } } public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { throw new SessionAccessDeniedException(batchId, "0"); } var updatedRows = await conn.ExecuteAsync( """ UPDATE sessions SET notification_mode = @NotificationMode, updated_at = now() WHERE batch_id = @BatchId AND group_id = @GroupId """, new { BatchId = batchId, GroupId = groupId, NotificationMode = notificationMode.ToDatabaseValue() }, transaction); if (updatedRows == 0) { throw new SessionAccessDeniedException(batchId, "0"); } await transaction.CommitAsync(); } public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var batchSessions = (await conn.QueryAsync( """ SELECT s.id AS Id, s.group_id AS GroupId, s.title AS Title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, s.status AS Status, s.max_players AS MaxPlayers, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.thread_id AS ThreadId, s.topic_created_by_bot AS TopicCreatedByBot, s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId AND s.group_id = @GroupId ORDER BY s.scheduled_at FOR UPDATE """, new { BatchId = batchId, GroupId = groupId }, transaction)).ToList(); if (batchSessions.Count == 0) { throw new SessionAccessDeniedException(batchId, "0"); } var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( batchSessions.Select(session => session.ScheduledAt), firstScheduledAt, intervalDays); for (var index = 0; index < batchSessions.Count; index++) { await conn.ExecuteAsync( """ UPDATE sessions SET scheduled_at = @ScheduledAt, one_hour_reminder_processed_at = NULL, updated_at = now() WHERE id = @SessionId """, new { SessionId = batchSessions[index].Id, ScheduledAt = newSchedule[index] }, transaction); } await transaction.CommitAsync(); var firstSession = batchSessions[0]; if (firstSession.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title); } var notification = $"🔄 Мастер обновил расписание пачки\n\n" + $"📌 {System.Net.WebUtility.HtmlEncode(firstSession.Title)}\n" + $"🗓 Новое начало: {firstScheduledAt.FormatMoscow()} (МСК)\n" + $"↔️ Шаг: {intervalDays} дн."; await bot.SendMessage( chatId: firstSession.TelegramChatId, messageThreadId: firstSession.ThreadId, text: notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode); if (mode.ShouldSendDirectMessages()) { var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId); await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId); } } public async Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var sourceSessions = (await conn.QueryAsync( """ SELECT s.id AS Id, s.group_id AS GroupId, s.title AS Title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, s.status AS Status, s.max_players AS MaxPlayers, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.thread_id AS ThreadId, s.topic_created_by_bot AS TopicCreatedByBot, s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId AND s.group_id = @GroupId ORDER BY s.scheduled_at FOR UPDATE """, new { BatchId = batchId, GroupId = groupId }, transaction)).ToList(); if (sourceSessions.Count == 0) { throw new SessionAccessDeniedException(batchId, "0"); } var newBatchId = Guid.NewGuid(); var batchTitle = sourceSessions[0].Title; var batchJoinLink = sourceSessions[0].JoinLink; var chatId = sourceSessions[0].TelegramChatId; var threadId = sourceSessions[0].ThreadId; var renderedSessions = new List(); foreach (var sourceSession in sourceSessions) { var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval); var sessionId = await conn.ExecuteScalarAsync( """ INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode) RETURNING id """, new { BatchId = newBatchId, sourceSession.GroupId, Title = batchTitle, JoinLink = batchJoinLink, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, ThreadId = threadId, sourceSession.TopicCreatedByBot, sourceSession.MaxPlayers, sourceSession.NotificationMode }, transaction); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink)); } await transaction.CommitAsync(); var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty()); var renderResult = TelegramSessionBatchRenderer.Render(view); var batchMessage = await bot.SendMessage( chatId: chatId, messageThreadId: threadId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup); await conn.ExecuteAsync( "UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId", new { MessageId = batchMessage.MessageId, BatchId = newBatchId }); return new WebSessionBatch( newBatchId, groupId, batchTitle, batchJoinLink, renderedSessions.Min(session => session.ScheduledAt), renderedSessions.Max(session => session.ScheduledAt), renderedSessions.Count, sourceSessions[0].NotificationMode); } public async Task> GetCampaignTemplatesAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt FROM campaign_templates WHERE group_id = @GroupId ORDER BY created_at DESC, name """, new { GroupId = groupId })).ToList(); } public async Task GetCampaignTemplateAsync(Guid templateId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( """ SELECT id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt FROM campaign_templates WHERE id = @TemplateId """, new { TemplateId = templateId }); } public async Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleAsync( """ INSERT INTO campaign_templates ( group_id, name, title, join_link, session_count, interval_days, max_players, notification_mode ) VALUES ( @GroupId, @Name, @Title, @JoinLink, @SessionCount, @IntervalDays, @MaxPlayers, @NotificationMode ) ON CONFLICT (group_id, name) DO UPDATE SET title = EXCLUDED.title, join_link = EXCLUDED.join_link, session_count = EXCLUDED.session_count, interval_days = EXCLUDED.interval_days, max_players = EXCLUDED.max_players, notification_mode = EXCLUDED.notification_mode, updated_at = now() RETURNING id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt """, new { GroupId = groupId, request.Name, request.Title, request.JoinLink, request.SessionCount, request.IntervalDays, request.MaxPlayers, NotificationMode = request.NotificationMode.ToDatabaseValue() }); } public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( "DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId", new { TemplateId = templateId, GroupId = groupId }); } public async Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var template = await conn.QuerySingleOrDefaultAsync( """ SELECT id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId FOR UPDATE """, new { TemplateId = templateId, GroupId = groupId }, transaction); if (template is null) { throw new SessionAccessDeniedException(templateId, "0"); } var group = await conn.QuerySingleOrDefaultAsync( "SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId", new { GroupId = groupId }, transaction); if (group is null) { throw new SessionAccessDeniedException(groupId, "0"); } var schedule = BatchSchedulePlanner.BuildRecurringSchedule( firstScheduledAt, template.SessionCount, template.IntervalDays); var batchId = Guid.NewGuid(); var renderedSessions = new List(); foreach (var scheduledAt in schedule) { var sessionId = await conn.ExecuteScalarAsync( """ INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode) RETURNING id """, new { BatchId = batchId, GroupId = groupId, template.Title, template.JoinLink, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, template.MaxPlayers, template.NotificationMode }, transaction); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink)); } await transaction.CommitAsync(); var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty()); var renderResult = TelegramSessionBatchRenderer.Render(view); var batchMessage = await bot.SendMessage( chatId: group.TelegramChatId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup); await conn.ExecuteAsync( "UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId", new { MessageId = batchMessage.MessageId, BatchId = batchId }); return new WebSessionBatch( batchId, groupId, template.Title, template.JoinLink, renderedSessions.Min(session => session.ScheduledAt), renderedSessions.Max(session => session.ScheduledAt), renderedSessions.Count, template.NotificationMode); } private async Task> LoadSessionDirectRecipientsAsync( Npgsql.NpgsqlConnection conn, Guid sessionId) { return (await conn.QueryAsync( """ SELECT p.telegram_id AS TelegramId, 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 = @Active """, new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); } private async Task> LoadBatchDirectRecipientsAsync( Npgsql.NpgsqlConnection conn, Guid batchId) { return (await conn.QueryAsync( """ SELECT DISTINCT p.telegram_id AS TelegramId, p.display_name AS DisplayName FROM session_participants sp JOIN players p ON p.id = sp.player_id JOIN sessions s ON s.id = sp.session_id WHERE s.batch_id = @BatchId AND sp.is_gm = false AND sp.registration_status = @Active """, new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList(); } private async Task SendDirectNotificationsAsync( IEnumerable recipients, string htmlText, string notificationKind, Guid entityId) { foreach (var recipient in recipients) { try { await bot.SendMessage( chatId: recipient.TelegramId, text: htmlText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); } catch (Exception ex) { logger.LogWarning( ex, "Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})", notificationKind, entityId, recipient.TelegramId, recipient.DisplayName); } } } private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title) { try { await using var conn = await dataSource.OpenConnectionAsync(); var sessions = (await conn.QueryAsync( "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { BatchId = batchId })).ToList(); var participants = (await conn.QueryAsync( @"SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, sp.registration_status AS RegistrationStatus FROM session_participants sp JOIN players p ON sp.player_id = p.id JOIN sessions s ON sp.session_id = s.id WHERE s.batch_id = @BatchId AND sp.is_gm = false ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", new { BatchId = batchId })).ToList(); var view = SessionBatchViewBuilder.Build(title, sessions, participants); var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, chatId: chatId, messageId: messageId, text: renderResult.Text, replyMarkup: renderResult.Markup); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId); } } private static async Task GetBatchInfoAsync( Npgsql.NpgsqlConnection conn, Guid batchId, Guid groupId, Npgsql.NpgsqlTransaction transaction) { return await conn.QuerySingleOrDefaultAsync( """ SELECT s.batch_id AS BatchId, s.group_id AS GroupId, (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, g.telegram_chat_id AS TelegramChatId, (array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId, (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId, (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId AND s.group_id = @GroupId GROUP BY s.batch_id, s.group_id, g.telegram_chat_id """, new { BatchId = batchId, GroupId = groupId }, transaction); } // --- Identity linking (issue #35) --- public async Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); } public async Task> GetLinkedIdentitiesAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); if (effectiveId is null) return []; return (await conn.QueryAsync( """ SELECT p.platform AS Platform, p.external_user_id AS ExternalUserId, p.display_name AS DisplayName, p.external_username AS ExternalUsername, p.avatar_url AS AvatarUrl, COALESCE(pl.linked_at, p.created_at) AS LinkedAt FROM players p LEFT JOIN player_links pl ON pl.secondary_player_id = p.id WHERE pl.primary_player_id = @EffectiveId OR p.id = @EffectiveId ORDER BY CASE WHEN p.id = @EffectiveId THEN 0 ELSE 1 END, p.platform """, new { EffectiveId = effectiveId.Value })).ToList(); } public async Task LinkIdentityAsync( string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) { if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId) throw new InvalidOperationException("Cannot link an identity to itself."); await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); // Resolve current player (must exist — they are logged in) var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId); if (currentPlayerId is null) throw new InvalidOperationException("Current player not found."); // Upsert target player so it exists var targetDisplayName = currentName ?? $"{targetPlatform} {targetExternalUserId}"; var targetPlayerId = await _UpsertPlayerAndGetIdAsync(conn, targetPlatform, targetExternalUserId, targetDisplayName, null, transaction); // Check if target is already a primary of another link chain (conflict) var targetIsPrimary = await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM player_links WHERE primary_player_id = @TargetPlayerId ) """, new { TargetPlayerId = targetPlayerId }, transaction); if (targetIsPrimary) { await _LogIdentityAuditAsync(conn, currentPlayerId.Value, "link_attempt_conflict", targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); await transaction.CommitAsync(); throw new InvalidOperationException("Target identity is already the primary account of another linked set."); } // Check if current is already a secondary (then their primary becomes the effective primary) var currentPrimaryId = await conn.QuerySingleOrDefaultAsync( """ SELECT primary_player_id FROM player_links WHERE secondary_player_id = @CurrentPlayerId """, new { CurrentPlayerId = currentPlayerId.Value }, transaction); var effectiveCurrentPrimary = currentPrimaryId ?? currentPlayerId.Value; // Check if target is already linked to someone else as secondary var existingLink = await conn.QuerySingleOrDefaultAsync( """ SELECT primary_player_id FROM player_links WHERE secondary_player_id = @TargetPlayerId """, new { TargetPlayerId = targetPlayerId }, transaction); if (existingLink is not null && existingLink.Value != effectiveCurrentPrimary) { await _LogIdentityAuditAsync(conn, effectiveCurrentPrimary, "link_attempt_conflict", targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); await transaction.CommitAsync(); throw new InvalidOperationException("Target identity is already linked to another account."); } var effectivePrimary = currentPrimaryId ?? currentPlayerId.Value; // Check if already linked var alreadyLinked = await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM player_links WHERE primary_player_id = @EffectivePrimary AND secondary_player_id = @TargetPlayerId ) """, new { EffectivePrimary = effectivePrimary, TargetPlayerId = targetPlayerId }, transaction); if (alreadyLinked) { await transaction.CommitAsync(); return; // Already linked, idempotent } await conn.ExecuteAsync( """ INSERT INTO player_links (primary_player_id, secondary_player_id, linked_by_player_id) VALUES (@PrimaryPlayerId, @SecondaryPlayerId, @LinkedByPlayerId) """, new { PrimaryPlayerId = effectivePrimary, SecondaryPlayerId = targetPlayerId, LinkedByPlayerId = currentPlayerId.Value }, transaction); await _LogIdentityAuditAsync(conn, effectivePrimary, "link", targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); await transaction.CommitAsync(); } public async Task UnlinkIdentityAsync( string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) { if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId) throw new InvalidOperationException("Cannot unlink your own primary identity from itself."); await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId); if (currentPlayerId is null) throw new InvalidOperationException("Current player not found."); var targetPlayerId = await _ResolvePlayerIdAsync(conn, targetPlatform, targetExternalUserId); if (targetPlayerId is null) throw new InvalidOperationException("Target identity not found."); var effectivePrimary = await _ResolveEffectivePlayerIdAsync(conn, currentPlatform, currentExternalUserId); if (effectivePrimary is null) throw new InvalidOperationException("Effective primary not found."); // Only the primary account owner (or the linked identity itself) can unlink var rows = await conn.ExecuteAsync( """ DELETE FROM player_links WHERE primary_player_id = @EffectivePrimary AND secondary_player_id = @TargetPlayerId """, new { EffectivePrimary = effectivePrimary.Value, TargetPlayerId = targetPlayerId.Value }, transaction); if (rows == 0) { await transaction.RollbackAsync(); throw new InvalidOperationException("Identity is not linked to your account."); } await _LogIdentityAuditAsync(conn, effectivePrimary.Value, "unlink", targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); await transaction.CommitAsync(); } public async Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) { await using var conn = await dataSource.OpenConnectionAsync(); await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, avatarUrl, null); } public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) { await using var conn = await dataSource.OpenConnectionAsync(); await _UpsertPlayerAndGetIdAsync(conn, "Discord", discordId, displayName, avatarUrl, null); } // --- Private helpers --- private static async Task _ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) { return await conn.QuerySingleOrDefaultAsync( """ SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId """, new { Platform = platform, ExternalUserId = externalUserId }); } private static async Task _ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) { var playerId = await _ResolvePlayerIdAsync(conn, platform, externalUserId); if (playerId is null) return null; var primaryId = await conn.QuerySingleOrDefaultAsync( """ SELECT primary_player_id FROM player_links WHERE secondary_player_id = @PlayerId """, new { PlayerId = playerId.Value }); return primaryId ?? playerId; } private static async Task _UpsertPlayerAndGetIdAsync( NpgsqlConnection conn, string platform, string externalUserId, string displayName, string? avatarUrl, NpgsqlTransaction? transaction) { return await conn.QuerySingleAsync( """ INSERT INTO players (display_name, platform, external_user_id, external_username, avatar_url) VALUES (@DisplayName, @Platform, @ExternalUserId, @DisplayName, @AvatarUrl) ON CONFLICT (platform, external_user_id) WHERE platform IS NOT NULL AND external_user_id IS NOT NULL DO UPDATE SET display_name = EXCLUDED.display_name, external_username = EXCLUDED.external_username, avatar_url = COALESCE(EXCLUDED.avatar_url, players.avatar_url) RETURNING id """, new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId, AvatarUrl = avatarUrl }, transaction); } private static async Task _LogIdentityAuditAsync( NpgsqlConnection conn, Guid playerId, string action, string? targetPlatform, string? targetExternalUserId, Guid? performedByPlayerId, NpgsqlTransaction? transaction) { await conn.ExecuteAsync( """ INSERT INTO identity_audit_log (player_id, action, target_platform, target_external_user_id, performed_by_player_id) VALUES (@PlayerId, @Action, @TargetPlatform, @TargetExternalUserId, @PerformedByPlayerId) """, new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId }, transaction); } }