namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using Npgsql; using NetCord.Rest; public sealed record DiscordRescheduleVoteInput( Guid OptionId, ulong UserId, string InteractionId, string GuildId, string ChannelId, string MessageId); public sealed class DiscordRescheduleVoteHandler( NpgsqlDataSource dataSource, RestClient restClient, ILogger logger) { public async Task HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); // 1. Load proposal + option var proposal = await connection.QuerySingleOrDefaultAsync( """ SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt, s.title AS Title, s.scheduled_at AS CurrentScheduledAt FROM reschedule_options ro JOIN reschedule_proposals rp ON rp.id = ro.proposal_id JOIN sessions s ON s.id = rp.session_id WHERE ro.id = @OptionId AND rp.status = 'Voting' """, new { input.OptionId }, transaction); if (proposal is null) return "Голосование уже завершено или не найдено."; if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) return "Дедлайн уже прошёл. Результаты скоро будут применены."; // 2. Verify participant (Discord platform) var playerId = await connection.ExecuteScalarAsync( """ SELECT p.id FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND p.platform = 'Discord' AND p.external_user_id = @UserId AND sp.is_gm = false AND sp.registration_status = @Active """, new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active }, transaction); if (playerId is null) return "Вы не являетесь участником этой сессии."; // 3. Upsert vote await connection.ExecuteAsync( """ INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) VALUES (@ProposalId, @PlayerId, @OptionId) ON CONFLICT (proposal_id, player_id) DO UPDATE SET option_id = EXCLUDED.option_id, voted_at = now() """, new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId }, transaction); // 4. Reload participants, options, votes for re-rendering var participants = (await connection.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 ORDER BY p.display_name """, new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, transaction)).ToList(); var options = (await connection.QueryAsync( """ SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt FROM reschedule_options WHERE proposal_id = @ProposalId ORDER BY display_order """, new { ProposalId = proposal.Id }, transaction)).ToList(); var votes = (await connection.QueryAsync( """ SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername FROM reschedule_option_votes rov JOIN players p ON p.id = rov.player_id WHERE rov.proposal_id = @ProposalId ORDER BY rov.voted_at, p.display_name """, new { ProposalId = proposal.Id }, transaction)).ToList(); await transaction.CommitAsync(ct); // 5. Re-render and update Discord vote message var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, options, participants, votes); var channelIdUlong = ulong.Parse(input.ChannelId); var messageIdUlong = ulong.Parse(input.MessageId); try { await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options => { options.Embeds = new[] { embed }; options.Components = new[] { actionRow }; }); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id); } return "Ваш голос учтён. До дедлайна его можно изменить."; } }