From a13edf20afb13b75017ebaf3f323b2c4de6a267b Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 11:54:53 +0300 Subject: [PATCH] feat(shared): add RescheduleVotingFinalizer and ISystemClock --- .../RescheduleVotingDeadlineService.cs | 272 +++++------------- .../Infrastructure/Scheduling/ISystemClock.cs | 7 +- .../Scheduling/SessionSchedulerService.cs | 1 + src/GmRelay.Bot/Program.cs | 2 + .../RescheduleVotingFinalizer.cs | 214 ++++++++++++++ src/GmRelay.Shared/Platform/ISystemClock.cs | 6 + .../TelegramTopicIntegrationSmokeTests.cs | 2 +- 7 files changed, 298 insertions(+), 206 deletions(-) create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs create mode 100644 src/GmRelay.Shared/Platform/ISystemClock.cs diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index a60df94..ffd21e8 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -1,6 +1,5 @@ using Dapper; using GmRelay.Bot.Features.Notifications; -using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; @@ -12,25 +11,18 @@ using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; -internal sealed record DueRescheduleProposalDto( - Guid Id, - Guid SessionId, - DateTimeOffset VotingDeadlineAt, - string Title, - DateTime CurrentScheduledAt, - Guid BatchId, - int? BatchMessageId, +internal sealed record TelegramProposalFieldsDto( int? VoteMessageId, + int? BatchMessageId, long TelegramChatId, - int? ThreadId, - string NotificationMode); + int? ThreadId); public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, ITelegramBotClient bot, IPlatformMessenger messenger, DirectSessionNotificationSender directSender, - ISystemClock clock, + RescheduleVotingFinalizer finalizer, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -54,18 +46,7 @@ public sealed class RescheduleVotingDeadlineService( { 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 - """, - new { Now = clock.UtcNow.UtcDateTime })).ToList(); + var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); foreach (var proposalId in proposalIds) { @@ -83,212 +64,105 @@ public sealed class RescheduleVotingDeadlineService( 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 result = await finalizer.FinalizeAsync(proposalId, ct); + if (result is null) + return; - var proposal = await connection.QuerySingleOrDefaultAsync( + 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( """ - 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, + SELECT rp.vote_message_id AS VoteMessageId, s.batch_message_id AS BatchMessageId, - s.notification_mode AS NotificationMode, - s.thread_id AS ThreadId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.thread_id AS ThreadId 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, Now = clock.UtcNow.UtcDateTime }, - transaction); + new { ProposalId = proposalId }); - if (proposal is null) + if (telegramFields is null) + { + logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId); 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, - 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)) .ToList(); - await transaction.CommitAsync(ct); + await TryUpdateVoteMessage(result, telegramFields, ct); - await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct); - - if (selectedOption is not null) + if (result.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()) { - await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct); + await SendDirectResult(result, directRecipients, ct); } logger.LogInformation( - "Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", - proposal.Id, - proposal.SessionId, - decision.Outcome); + "Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}", + result.ProposalId, + result.SessionId); } private async Task TryUpdateVoteMessage( - DueRescheduleProposalDto proposal, - IReadOnlyList options, - IReadOnlyList participants, - IReadOnlyList votes, - RescheduleVoteDecision decision, - RescheduleOptionDto? selectedOption, + RescheduleVotingFinalizerResult result, + TelegramProposalFieldsDto telegramFields, CancellationToken ct) { - if (proposal.VoteMessageId is null) + if (telegramFields.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 resultText = result.SelectedOption is not null + ? $"✅ Голосование завершено.\nПобедил вариант {result.SelectedOption.DisplayOrder}: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК)." + : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}"; var text = $""" {HandleRescheduleTimeInputHandler.BuildVotingMessage( - proposal.Title, - proposal.CurrentScheduledAt, - proposal.VotingDeadlineAt, - options, - participants, - votes)} + result.Title, + result.CurrentScheduledAt, + result.VotingDeadlineAt, + result.Options, + result.Participants, + result.Votes)} {resultText} """; await bot.EditMessageText( - chatId: proposal.TelegramChatId, - messageId: proposal.VoteMessageId.Value, + chatId: telegramFields.TelegramChatId, + messageId: telegramFields.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); + 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 { @@ -296,7 +170,7 @@ public sealed class RescheduleVotingDeadlineService( var batchSessions = (await connection.QueryAsync( "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( """ @@ -310,60 +184,58 @@ public sealed class RescheduleVotingDeadlineService( 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(); + 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( new PlatformScheduleMessage( - TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), + TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), view, - TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)), + TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)), ct); } else { await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), - $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", + TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), + $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».", ct); } } 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( - DueRescheduleProposalDto proposal, + RescheduleVotingFinalizerResult result, IReadOnlyList recipients, - RescheduleVoteDecision decision, - RescheduleOptionDto? selectedOption, CancellationToken ct) { - var htmlText = selectedOption is not null + var htmlText = result.SelectedOption is not null ? $""" ✅ Сессия перенесена по итогам голосования - 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} - 📅 Новое время: {selectedOption.ProposedAt.FormatMoscow()} (МСК) + 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} + 📅 Новое время: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК) """ : $""" ❌ Перенос сессии отклонён по итогам голосования - 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} - 📅 Время остаётся прежним: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) - Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)} + 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} + 📅 Время остаётся прежним: {result.CurrentScheduledAt.FormatMoscow()} (МСК) + Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)} """; await directSender.SendAsync( recipients, htmlText, - selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", - proposal.SessionId, + result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", + result.SessionId, ct); } } diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs index 46e0888..eb87a64 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs @@ -1,9 +1,6 @@ -namespace GmRelay.Bot.Infrastructure.Scheduling; +using GmRelay.Shared.Platform; -public interface ISystemClock -{ - DateTimeOffset UtcNow { get; } -} +namespace GmRelay.Bot.Infrastructure.Scheduling; public sealed class SystemClock : ISystemClock { diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs index 31b227a..c7e89ee 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs @@ -1,6 +1,7 @@ using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendOneHourReminder; +using GmRelay.Shared.Platform; namespace GmRelay.Bot.Infrastructure.Scheduling; diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index 38abc3e..c47606f 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -6,6 +6,7 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Database; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Health; using GmRelay.Bot.Infrastructure.Logging; using GmRelay.Bot.Infrastructure.Scheduling; @@ -75,6 +76,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // ── Telegram infrastructure ────────────────────────────────────────── builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs new file mode 100644 index 0000000..8942b4b --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs @@ -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 Options, + IReadOnlyList Votes, + IReadOnlyList Participants); + +public sealed class RescheduleVotingFinalizer( + NpgsqlDataSource dataSource, + ISystemClock clock, + ILogger logger) +{ + public async Task> GetDueProposalIdsAsync(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 + ORDER BY voting_deadline_at + LIMIT 25 + """, + new { Now = clock.UtcNow.UtcDateTime })).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.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.ProposalId }, + 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.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); +} diff --git a/src/GmRelay.Shared/Platform/ISystemClock.cs b/src/GmRelay.Shared/Platform/ISystemClock.cs new file mode 100644 index 0000000..a0d0a65 --- /dev/null +++ b/src/GmRelay.Shared/Platform/ISystemClock.cs @@ -0,0 +1,6 @@ +namespace GmRelay.Shared.Platform; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index 7d0a3ed..380bb31 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -54,7 +54,7 @@ public sealed class TelegramTopicIntegrationSmokeTests Assert.Contains("int? 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 ReadRepositoryFileAsync(string relativePath)