using Dapper; using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.Enums; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; internal sealed record DueRescheduleProposalDto( Guid Id, Guid SessionId, DateTimeOffset VotingDeadlineAt, string Title, DateTime CurrentScheduledAt, Guid BatchId, int? BatchMessageId, int? VoteMessageId, long TelegramChatId, string NotificationMode); public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, ITelegramBotClient bot, DirectSessionNotificationSender directSender, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { await ProcessDueProposals(stoppingToken); using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); while (await timer.WaitForNextTickAsync(stoppingToken)) { await ProcessDueProposals(stoppingToken); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } } private async Task ProcessDueProposals(CancellationToken ct) { try { 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() ORDER BY voting_deadline_at LIMIT 25 """)).ToList(); foreach (var proposalId in proposalIds) { await FinalizeProposal(proposalId, ct); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { } catch (Exception ex) { logger.LogError(ex, "Failed to process due reschedule voting proposals"); } } private async Task FinalizeProposal(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 Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt, rp.vote_message_id AS VoteMessageId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.notification_mode AS NotificationMode, g.telegram_chat_id AS TelegramChatId FROM reschedule_proposals rp JOIN sessions s ON s.id = rp.session_id JOIN game_groups g ON g.id = s.group_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 }, transaction); if (proposal is null) return; var participants = (await connection.QueryAsync( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, p.telegram_id 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.telegram_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(); 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, 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.Id, SelectedOptionId = selectedOption.OptionId, ProposedAt = selectedOption.ProposedAt }, transaction); } else { await connection.ExecuteAsync( "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", new { ProposalId = proposal.Id }, transaction); } var directRecipients = participants .Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)) .ToList(); await transaction.CommitAsync(ct); await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct); if (selectedOption is not null) { await TryUpdateBatchMessage(proposal, ct); } var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); if (mode.ShouldSendDirectMessages()) { await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct); } logger.LogInformation( "Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", proposal.Id, proposal.SessionId, decision.Outcome); } private async Task TryUpdateVoteMessage( DueRescheduleProposalDto proposal, IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes, RescheduleVoteDecision decision, RescheduleOptionDto? selectedOption, CancellationToken ct) { if (proposal.VoteMessageId is null) return; try { var resultText = selectedOption is not null ? $"✅ Голосование завершено.\nПобедил вариант {selectedOption.DisplayOrder}: {selectedOption.ProposedAt.FormatMoscow()} (МСК)." : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}"; var text = $""" {HandleRescheduleTimeInputHandler.BuildVotingMessage( proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, options, participants, votes)} {resultText} """; await bot.EditMessageText( chatId: proposal.TelegramChatId, messageId: proposal.VoteMessageId.Value, text: text, parseMode: ParseMode.Html, cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id); } } private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct) { try { await using var connection = await dataSource.OpenConnectionAsync(ct); var batchSessions = (await connection.QueryAsync( "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { proposal.BatchId })).ToList(); var batchParticipants = (await connection.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 { proposal.BatchId })).ToList(); if (proposal.BatchMessageId.HasValue) { var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); await bot.EditMessageText( chatId: proposal.TelegramChatId, messageId: proposal.BatchMessageId.Value, text: renderResult.Text, parseMode: ParseMode.Html, replyMarkup: renderResult.Markup, cancellationToken: ct); } else { await bot.SendMessage( chatId: proposal.TelegramChatId, text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", parseMode: ParseMode.Html, cancellationToken: ct); } } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id); } } private async Task SendDirectResult( DueRescheduleProposalDto proposal, IReadOnlyList recipients, RescheduleVoteDecision decision, RescheduleOptionDto? selectedOption, CancellationToken ct) { var htmlText = selectedOption is not null ? $""" ✅ Сессия перенесена по итогам голосования 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} 📅 Новое время: {selectedOption.ProposedAt.FormatMoscow()} (МСК) """ : $""" ❌ Перенос сессии отклонён по итогам голосования 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} 📅 Время остаётся прежним: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)} """; await directSender.SendAsync( recipients, htmlText, selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", proposal.SessionId, ct); } }