feat(shared): add RescheduleVotingFinalizer and ISystemClock
This commit is contained in:
+72
-200
@@ -1,6 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
@@ -12,25 +11,18 @@ using GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
internal sealed record DueRescheduleProposalDto(
|
internal sealed record TelegramProposalFieldsDto(
|
||||||
Guid Id,
|
|
||||||
Guid SessionId,
|
|
||||||
DateTimeOffset VotingDeadlineAt,
|
|
||||||
string Title,
|
|
||||||
DateTime CurrentScheduledAt,
|
|
||||||
Guid BatchId,
|
|
||||||
int? BatchMessageId,
|
|
||||||
int? VoteMessageId,
|
int? VoteMessageId,
|
||||||
|
int? BatchMessageId,
|
||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
int? ThreadId,
|
int? ThreadId);
|
||||||
string NotificationMode);
|
|
||||||
|
|
||||||
public sealed class RescheduleVotingDeadlineService(
|
public sealed class RescheduleVotingDeadlineService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IPlatformMessenger messenger,
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ISystemClock clock,
|
RescheduleVotingFinalizer finalizer,
|
||||||
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -54,18 +46,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var proposalIds = await finalizer.GetDueProposalIdsAsync(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
|
|
||||||
""",
|
|
||||||
new { Now = clock.UtcNow.UtcDateTime })).ToList();
|
|
||||||
|
|
||||||
foreach (var proposalId in proposalIds)
|
foreach (var proposalId in proposalIds)
|
||||||
{
|
{
|
||||||
@@ -83,212 +64,105 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
|
|
||||||
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
|
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var result = await finalizer.FinalizeAsync(proposalId, ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
if (result is null)
|
||||||
|
return;
|
||||||
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
|
if (result.SourcePlatform != "Telegram")
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
|
||||||
|
proposalId,
|
||||||
|
result.SourcePlatform);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id AS Id,
|
SELECT rp.vote_message_id AS VoteMessageId,
|
||||||
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.batch_message_id AS BatchMessageId,
|
||||||
s.notification_mode AS NotificationMode,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId
|
||||||
g.telegram_chat_id AS TelegramChatId
|
|
||||||
FROM reschedule_proposals rp
|
FROM reschedule_proposals rp
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE rp.id = @ProposalId
|
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 },
|
new { ProposalId = proposalId });
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (proposal is null)
|
if (telegramFields is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
|
||||||
return;
|
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,
|
|
||||||
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.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
|
var directRecipients = result.Participants
|
||||||
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
|
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await TryUpdateVoteMessage(result, telegramFields, ct);
|
||||||
|
|
||||||
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
|
if (result.SelectedOption is not null)
|
||||||
|
|
||||||
if (selectedOption is not null)
|
|
||||||
{
|
{
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
await TryUpdateBatchMessage(result, telegramFields, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
{
|
{
|
||||||
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
|
await SendDirectResult(result, directRecipients, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
|
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
|
||||||
proposal.Id,
|
result.ProposalId,
|
||||||
proposal.SessionId,
|
result.SessionId);
|
||||||
decision.Outcome);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryUpdateVoteMessage(
|
private async Task TryUpdateVoteMessage(
|
||||||
DueRescheduleProposalDto proposal,
|
RescheduleVotingFinalizerResult result,
|
||||||
IReadOnlyList<RescheduleOptionDto> options,
|
TelegramProposalFieldsDto telegramFields,
|
||||||
IReadOnlyList<VoteParticipantDto> participants,
|
|
||||||
IReadOnlyList<RescheduleOptionVoteDto> votes,
|
|
||||||
RescheduleVoteDecision decision,
|
|
||||||
RescheduleOptionDto? selectedOption,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (proposal.VoteMessageId is null)
|
if (telegramFields.VoteMessageId is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resultText = selectedOption is not null
|
var resultText = result.SelectedOption is not null
|
||||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {result.SelectedOption.DisplayOrder}: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||||
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
|
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}";
|
||||||
|
|
||||||
var text = $"""
|
var text = $"""
|
||||||
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
proposal.Title,
|
result.Title,
|
||||||
proposal.CurrentScheduledAt,
|
result.CurrentScheduledAt,
|
||||||
proposal.VotingDeadlineAt,
|
result.VotingDeadlineAt,
|
||||||
options,
|
result.Options,
|
||||||
participants,
|
result.Participants,
|
||||||
votes)}
|
result.Votes)}
|
||||||
|
|
||||||
{resultText}
|
{resultText}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await bot.EditMessageText(
|
||||||
chatId: proposal.TelegramChatId,
|
chatId: telegramFields.TelegramChatId,
|
||||||
messageId: proposal.VoteMessageId.Value,
|
messageId: telegramFields.VoteMessageId.Value,
|
||||||
text: text,
|
text: text,
|
||||||
parseMode: ParseMode.Html,
|
parseMode: ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
|
private async Task TryUpdateBatchMessage(
|
||||||
|
RescheduleVotingFinalizerResult result,
|
||||||
|
TelegramProposalFieldsDto telegramFields,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -296,7 +170,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
|
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { proposal.BatchId })).ToList();
|
new { result.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
"""
|
"""
|
||||||
@@ -310,60 +184,58 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
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
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||||
""",
|
""",
|
||||||
new { proposal.BatchId })).ToList();
|
new { result.BatchId })).ToList();
|
||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
if (telegramFields.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
|
||||||
|
|
||||||
await messenger.UpdateScheduleAsync(
|
await messenger.UpdateScheduleAsync(
|
||||||
new PlatformScheduleMessage(
|
new PlatformScheduleMessage(
|
||||||
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||||
view,
|
view,
|
||||||
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await messenger.SendGroupMessageAsync(
|
await messenger.SendGroupMessageAsync(
|
||||||
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||||
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».",
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendDirectResult(
|
private async Task SendDirectResult(
|
||||||
DueRescheduleProposalDto proposal,
|
RescheduleVotingFinalizerResult result,
|
||||||
IReadOnlyList<DirectNotificationRecipient> recipients,
|
IReadOnlyList<DirectNotificationRecipient> recipients,
|
||||||
RescheduleVoteDecision decision,
|
|
||||||
RescheduleOptionDto? selectedOption,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var htmlText = selectedOption is not null
|
var htmlText = result.SelectedOption is not null
|
||||||
? $"""
|
? $"""
|
||||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||||
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
|
📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
|
||||||
"""
|
"""
|
||||||
: $"""
|
: $"""
|
||||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||||
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
|
Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await directSender.SendAsync(
|
await directSender.SendAsync(
|
||||||
recipients,
|
recipients,
|
||||||
htmlText,
|
htmlText,
|
||||||
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
|
result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
|
||||||
proposal.SessionId,
|
result.SessionId,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
public interface ISystemClock
|
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
{
|
|
||||||
DateTimeOffset UtcNow { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class SystemClock : ISystemClock
|
public sealed class SystemClock : ISystemClock
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Bot.Infrastructure.Database;
|
using GmRelay.Bot.Infrastructure.Database;
|
||||||
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Bot.Infrastructure.Health;
|
using GmRelay.Bot.Infrastructure.Health;
|
||||||
using GmRelay.Bot.Infrastructure.Logging;
|
using GmRelay.Bot.Infrastructure.Logging;
|
||||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
@@ -75,6 +76,7 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.Expor
|
|||||||
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
||||||
|
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||||
|
|
||||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<UpdateRouter>();
|
builder.Services.AddSingleton<UpdateRouter>();
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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<RescheduleOptionDto> Options,
|
||||||
|
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||||
|
IReadOnlyList<VoteParticipantDto> Participants);
|
||||||
|
|
||||||
|
public sealed class RescheduleVotingFinalizer(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ISystemClock clock,
|
||||||
|
ILogger<RescheduleVotingFinalizer> logger)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<Guid>> GetDueProposalIdsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
""",
|
||||||
|
new { Now = clock.UtcNow.UtcDateTime })).ToList();
|
||||||
|
|
||||||
|
return proposalIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RescheduleVotingFinalizerResult?> 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<ProposalRow>(
|
||||||
|
"""
|
||||||
|
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<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.ProposalId },
|
||||||
|
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.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public interface ISystemClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
+1
-1
@@ -54,7 +54,7 @@ public sealed class TelegramTopicIntegrationSmokeTests
|
|||||||
|
|
||||||
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
|||||||
Reference in New Issue
Block a user