namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using NetCord; using NetCord.Rest; using Npgsql; public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList Options, DateTimeOffset Deadline); public sealed class DiscordRescheduleHandler( NpgsqlDataSource dataSource, DiscordPermissionChecker permissionChecker, RestClient restClient, ILogger logger) { public async Task HandleAsync( string guildId, string channelId, ulong userId, string userDisplayName, ulong resolvedPermissions, ulong guildOwnerId, Guid sessionId, IReadOnlyList options, DateTimeOffset deadline, CancellationToken ct) { // 1. Permission check + read-only validation (before Discord message) await using var readConnection = await dataSource.OpenConnectionAsync(ct); var dbManagerUserIds = await readConnection.QueryAsync( @"SELECT CAST(p.external_user_id AS BIGINT) FROM group_managers gm JOIN players p ON p.id = gm.player_id JOIN game_groups g ON g.id = gm.group_id WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", new { GuildId = guildId }); if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) { throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии."); } // 2. Ensure player exists await readConnection.ExecuteAsync( @"INSERT INTO players (display_name, platform, external_user_id, external_username) VALUES (@Name, 'Discord', @UserId, @Name) 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", new { Name = userDisplayName, UserId = userId.ToString() }); // 3. Verify session exists var session = await readConnection.QuerySingleOrDefaultAsync( """ SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt FROM sessions s WHERE s.id = @SessionId AND s.status != @Cancelled """, new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled }); if (session is null) throw new InvalidOperationException("Сессия не найдена или отменена."); // 4. Check no active proposal var hasActive = await readConnection.ExecuteScalarAsync( "SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))", new { SessionId = sessionId }); if (hasActive) throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии."); // 5. Load participants for rendering var participants = (await readConnection.QueryAsync( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId 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(); // 6. Prepare proposal data var proposalId = Guid.NewGuid(); var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); // 7. Build and send Discord vote message BEFORE transaction var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); var channelIdUlong = ulong.Parse(channelId); // NOTE: Discord message is sent before DB transaction to avoid orphaned proposals // if the send fails. There is a negligible race window where the message is visible // before the DB commit; in practice users cannot click faster than the transaction commits. var sentMessage = await restClient.SendMessageAsync( channelIdUlong, new MessageProperties() .WithEmbeds(new[] { embed }) .WithComponents(new[] { actionRow })); // 8. Create proposal + options + platform_messages in transaction try { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); await connection.ExecuteAsync( """ INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at) VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline) """, new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime }, transaction); foreach (var option in optionDtos) { await connection.ExecuteAsync( """ INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) """, new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder }, transaction); } await connection.ExecuteAsync( """ INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose) VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote') """, new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() }, transaction); await transaction.CommitAsync(ct); } catch (Exception ex) { logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message"); try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ } throw; } logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId); return new DiscordRescheduleResult(proposalId, optionDtos, deadline); } } internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);