feat: add multi-option reschedule voting
This commit is contained in:
+361
@@ -0,0 +1,361 @@
|
||||
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<RescheduleVotingDeadlineService> 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<Guid>(
|
||||
"""
|
||||
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<DueRescheduleProposalDto>(
|
||||
"""
|
||||
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<VoteParticipantDto>(
|
||||
"""
|
||||
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<RescheduleOptionDto>(
|
||||
"""
|
||||
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<RescheduleOptionVoteDto>(
|
||||
"""
|
||||
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<RescheduleOptionDto> options,
|
||||
IReadOnlyList<VoteParticipantDto> participants,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> votes,
|
||||
RescheduleVoteDecision decision,
|
||||
RescheduleOptionDto? selectedOption,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (proposal.VoteMessageId is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var resultText = selectedOption is not null
|
||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||
: $"❌ <b>Голосование завершено.</b>\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<SessionBatchDto>(
|
||||
"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<ParticipantBatchDto>(
|
||||
"""
|
||||
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<DirectNotificationRecipient> recipients,
|
||||
RescheduleVoteDecision decision,
|
||||
RescheduleOptionDto? selectedOption,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var htmlText = selectedOption is not null
|
||||
? $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
|
||||
"""
|
||||
: $"""
|
||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
recipients,
|
||||
htmlText,
|
||||
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
|
||||
proposal.SessionId,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user