040b0a3cdb
PR Checks / test-and-build (pull_request) Failing after 13m15s
- Добавлены миграции V024 (backfill + deprecation comments + calendar_subscriptions platform identity) и V025 (backfill proposed_by_external_user_id) - Все Bot handlers переведены с telegram_id/chat_id на platform + external_* - Shared handlers очищены от COALESCE fallback с telegram_* колонками - DiscordBot очищен от COALESCE fallback - Web SessionService и CalendarSubscriptionService переведены на external_* - HandleRsvpHandler: убран legacy UNION с gm_telegram_id, теперь только group_managers - RescheduleVotingFinalizer: переведен на external_username/external_user_id - Tests: добавлены asserts для V024/V025 - Версия обновлена до 3.1.0 Bump version → 3.1.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
216 lines
7.8 KiB
C#
216 lines
7.8 KiB
C#
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(string sourcePlatform, 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
|
|
AND source_platform = @SourcePlatform
|
|
ORDER BY voting_deadline_at
|
|
LIMIT 25
|
|
""",
|
|
new { Now = clock.UtcNow.UtcDateTime, SourcePlatform = sourcePlatform })).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.external_username AS TelegramUsername,
|
|
p.external_user_id::BIGINT 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.external_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);
|
|
}
|