using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; public sealed record HandleRescheduleVoteCommand( Guid ProposalId, string Vote, long TelegramUserId, string CallbackQueryId, long ChatId, int MessageId); internal sealed record VoteProposalDto( Guid Id, Guid SessionId, DateTime ProposedAt, string Title, DateTime CurrentScheduledAt, Guid BatchId, string SessionStatus, long TelegramChatId, int? ConfirmationMessageId, int? BatchMessageId); public sealed class HandleRescheduleVoteHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, ILogger logger) { public async Task HandleAsync(HandleRescheduleVoteCommand command, 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.proposed_at AS ProposedAt, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, s.batch_id AS BatchId, s.status AS SessionStatus, s.confirmation_message_id AS ConfirmationMessageId, s.batch_message_id AS BatchMessageId, 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' """, new { command.ProposalId }, transaction); if (proposal is null) { await bot.AnswerCallbackQuery( command.CallbackQueryId, "Голосование уже завершено или не найдено.", cancellationToken: ct); return; } 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.telegram_id = @TelegramUserId AND sp.is_gm = false """, new { proposal.SessionId, command.TelegramUserId }, transaction); if (playerId is null) { await bot.AnswerCallbackQuery( command.CallbackQueryId, "Вы не являетесь участником этой сессии.", cancellationToken: ct); return; } await connection.ExecuteAsync( """ INSERT INTO reschedule_votes (proposal_id, player_id, vote) VALUES (@ProposalId, @PlayerId, @Vote) ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now() """, new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, transaction); var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) ? new List() : (await connection.QueryAsync( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND sp.is_gm = false """, new { proposal.SessionId }, transaction)).ToList(); var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) ? new HashSet() : (await connection.QueryAsync( """ SELECT player_id FROM reschedule_votes WHERE proposal_id = @ProposalId AND vote = 'yes' """, new { command.ProposalId }, transaction)).ToHashSet(); var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count); if (decision.Outcome == RescheduleVoteOutcome.Rejected) { await connection.ExecuteAsync( "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id", new { Id = command.ProposalId }, transaction); await transaction.CommitAsync(ct); var voterName = await connection.QuerySingleOrDefaultAsync( "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", new { command.TelegramUserId }); try { await bot.EditMessageText( chatId: command.ChatId, messageId: command.MessageId, text: $"❌ Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 {proposal.CurrentScheduledAt.FormatMoscow()} (МСК)", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update vote message after rejection"); } await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); return; } if (decision.ShouldRescheduleSession) { var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); await connection.ExecuteAsync( """ UPDATE sessions SET scheduled_at = @NewTime, status = @Status, confirmation_message_id = NULL, link_message_id = NULL, updated_at = now() WHERE id = @SessionId """, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, transaction); await connection.ExecuteAsync( "UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id", new { Id = command.ProposalId }, transaction); if (decision.ShouldResetParticipantRsvps) { await connection.ExecuteAsync( """ UPDATE session_participants SET rsvp_status = 'Pending', responded_at = NULL WHERE session_id = @SessionId AND is_gm = false """, new { proposal.SessionId }, transaction); } await transaction.CommitAsync(ct); try { await bot.EditMessageText( chatId: command.ChatId, messageId: command.MessageId, text: $"✅ Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!\n\nВсе участники согласились.\n📅 Новое время: {proposal.ProposedAt.FormatMoscow()} (МСК)\n\nУведомления будут приходить согласно новому расписанию.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update vote message after approval"); } await TryUpdateBatchMessage(proposal, ct); logger.LogInformation( "Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", proposal.SessionId, newTime, command.ProposalId); } else { await transaction.CommitAsync(ct); var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( proposal.Title, proposal.CurrentScheduledAt, new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero), participants, approvedPlayerIds); var keyboard = new InlineKeyboardMarkup([ [ InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"), InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}") ] ]); try { await bot.EditMessageText( chatId: command.ChatId, messageId: command.MessageId, text: voteText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: keyboard, cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update vote message with progress"); } } await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); } private async Task TryUpdateBatchMessage(VoteProposalDto 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 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 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.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: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup, cancellationToken: ct); } else { await bot.SendMessage( chatId: proposal.TelegramChatId, text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на {proposal.ProposedAt.FormatMoscow()} (МСК).", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); } } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id); } } }