using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging; using Npgsql; namespace GmRelay.Shared.Features.Sessions.RescheduleSession; public sealed record RescheduleVotingFinalizerResult( Guid ProposalId, Guid SessionId, string Title, DateTime CurrentScheduledAt, Guid BatchId, string NotificationMode, string SourcePlatform, DateTimeOffset VotingDeadlineAt, RescheduleVoteDecision Decision, RescheduleOptionDto? SelectedOption, IReadOnlyList Options, IReadOnlyList Votes, IReadOnlyList Participants); public sealed class RescheduleVotingFinalizer( NpgsqlDataSource dataSource, ISystemClock clock, ILogger logger) { public async Task> GetDueProposalIdsAsync(string sourcePlatform, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var proposalIds = (await connection.QueryAsync( """ SELECT id FROM reschedule_proposals WHERE status = 'Voting' AND voting_deadline_at IS NOT NULL AND voting_deadline_at <= @Now AND source_platform = @SourcePlatform ORDER BY voting_deadline_at LIMIT 25 """, new { Now = clock.UtcNow.UtcDateTime, SourcePlatform = sourcePlatform })).ToList(); return proposalIds; } public async Task FinalizeAsync(Guid proposalId, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); var proposal = await connection.QuerySingleOrDefaultAsync( """ SELECT rp.id AS ProposalId, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt, rp.source_platform AS SourcePlatform, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, s.batch_id AS BatchId, s.notification_mode AS NotificationMode FROM reschedule_proposals rp JOIN sessions s ON s.id = rp.session_id WHERE rp.id = @ProposalId AND rp.status = 'Voting' AND rp.voting_deadline_at IS NOT NULL AND rp.voting_deadline_at <= @Now FOR UPDATE """, new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, transaction); if (proposal is null) return null; var participants = (await connection.QueryAsync( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, p.external_user_id::BIGINT 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.ProposalId }, 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.ProposalId }, transaction)).ToList(); var voteCounts = options .Select(option => new RescheduleOptionVoteCount( option.OptionId, votes.Count(vote => vote.OptionId == option.OptionId))) .ToList(); var decision = RescheduleVoteRules.SelectWinner(voteCounts); var selectedOption = decision.SelectedOptionId is { } selectedOptionId ? options.Single(x => x.OptionId == selectedOptionId) : null; if (selectedOption is not null) { await connection.ExecuteAsync( """ UPDATE sessions SET scheduled_at = @NewTime, status = @Status, confirmation_message_id = NULL, confirmation_sent_at = NULL, link_message_id = NULL, one_hour_reminder_processed_at = NULL, updated_at = now() WHERE id = @SessionId """, new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned }, transaction); await connection.ExecuteAsync( """ UPDATE session_participants SET rsvp_status = 'Pending', responded_at = NULL WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active """, new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, transaction); await connection.ExecuteAsync( """ UPDATE reschedule_proposals SET status = 'Approved', selected_option_id = @SelectedOptionId, proposed_at = @ProposedAt WHERE id = @ProposalId """, new { ProposalId = proposal.ProposalId, SelectedOptionId = selectedOption.OptionId, ProposedAt = selectedOption.ProposedAt }, transaction); } else { await connection.ExecuteAsync( "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", new { ProposalId = proposal.ProposalId }, transaction); } await transaction.CommitAsync(ct); logger.LogInformation( "Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", proposal.ProposalId, proposal.SessionId, decision.Outcome); return new RescheduleVotingFinalizerResult( proposal.ProposalId, proposal.SessionId, proposal.Title, proposal.CurrentScheduledAt, proposal.BatchId, proposal.NotificationMode, proposal.SourcePlatform, proposal.VotingDeadlineAt, decision, selectedOption, options, votes, participants); } private sealed record ProposalRow( Guid ProposalId, Guid SessionId, DateTimeOffset VotingDeadlineAt, string SourcePlatform, string Title, DateTime CurrentScheduledAt, Guid BatchId, string NotificationMode); }