refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler, ExportCalendarHandler, HandleRescheduleTimeInputHandler, HandleRescheduleVoteHandler to GmRelay.Shared - Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync, SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync - Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers - Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler - Update UpdateRouter with explicit type aliases for ambiguous handler names - Add contract and source-inspection tests for extracted handlers - Bump version 3.1.1 → 3.2.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
Guid BatchId,
|
||||
int? BatchMessageId,
|
||||
string ExternalGroupId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleTimeInputCommand(
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string Text);
|
||||
|
||||
public sealed record HandleRescheduleVoteCommand(
|
||||
Guid OptionId,
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string InteractionId,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed class HandleRescheduleTimeInputHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
|
||||
HandleRescheduleTimeInputCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var platform = command.User.Platform.ToString();
|
||||
var externalGmId = command.User.ExternalUserId;
|
||||
var externalGroupId = command.Group.ExternalGroupId;
|
||||
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
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.proposed_by_external_user_id = @ExternalGmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.platform = @Platform
|
||||
AND g.external_group_id = @ExternalGroupId
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.platform = @Platform
|
||||
AND manager_player.external_user_id = @ExternalGmId
|
||||
)
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
|
||||
|
||||
if (proposal is null)
|
||||
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
|
||||
|
||||
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||
{
|
||||
return new HandleRescheduleTimeInputResult(
|
||||
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, 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
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
var newTime = votingInput.Options[0];
|
||||
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
|
||||
var replyText =
|
||||
$"""✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>""";
|
||||
return new HandleRescheduleTimeInputResult(
|
||||
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var options = votingInput.Options
|
||||
.Select((proposedAt, index) => new RescheduleOptionDto(
|
||||
Guid.NewGuid(),
|
||||
index + 1,
|
||||
proposedAt))
|
||||
.ToList();
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE reschedule_proposals
|
||||
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
|
||||
WHERE id = @Id
|
||||
""",
|
||||
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
foreach (var option in options)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||
""",
|
||||
new
|
||||
{
|
||||
option.OptionId,
|
||||
ProposalId = proposal.Id,
|
||||
option.ProposedAt,
|
||||
option.DisplayOrder
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
return new HandleRescheduleTimeInputResult(
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
proposal.Id,
|
||||
votingInput.Deadline,
|
||||
options,
|
||||
participants,
|
||||
[],
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
null);
|
||||
}
|
||||
|
||||
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
|
||||
NpgsqlConnection connection,
|
||||
AwaitingProposalDto proposal,
|
||||
DateTimeOffset newTime,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @NewTime,
|
||||
status = @Status,
|
||||
confirmation_message_id = NULL,
|
||||
confirmation_sent_at = NULL,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
||||
new { NewTime = newTime, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
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",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_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();
|
||||
|
||||
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleTimeInputResult(
|
||||
bool Handled,
|
||||
bool IsRescheduledImmediately,
|
||||
string? ReplyText,
|
||||
SessionBatchViewModel? UpdatedView,
|
||||
Guid? ProposalId,
|
||||
DateTimeOffset? VotingDeadlineAt,
|
||||
IReadOnlyList<RescheduleOptionDto> Options,
|
||||
IReadOnlyList<VoteParticipantDto> Participants,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||
string? Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
int? BatchMessageId);
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed class HandleRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<HandleRescheduleVoteResult> 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<VoteProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id,
|
||||
rp.session_id AS SessionId,
|
||||
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS CurrentScheduledAt
|
||||
FROM reschedule_options ro
|
||||
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
||||
""",
|
||||
new { command.OptionId },
|
||||
transaction);
|
||||
|
||||
if (proposal is null)
|
||||
{
|
||||
return new HandleRescheduleVoteResult(
|
||||
false,
|
||||
"Голосование уже завершено или не найдено.",
|
||||
null, null, null, default, default,
|
||||
Array.Empty<VoteParticipantDto>(),
|
||||
Array.Empty<RescheduleOptionDto>(),
|
||||
Array.Empty<RescheduleOptionVoteDto>());
|
||||
}
|
||||
|
||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return new HandleRescheduleVoteResult(
|
||||
false,
|
||||
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
||||
null, null, null, default, default,
|
||||
Array.Empty<VoteParticipantDto>(),
|
||||
Array.Empty<RescheduleOptionDto>(),
|
||||
Array.Empty<RescheduleOptionVoteDto>());
|
||||
}
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT p.id
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
proposal.SessionId,
|
||||
Platform = command.User.Platform.ToString(),
|
||||
ExternalUserId = command.User.ExternalUserId,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (playerId is null)
|
||||
{
|
||||
return new HandleRescheduleVoteResult(
|
||||
false,
|
||||
"Вы не являетесь участником этой сессии.",
|
||||
null, null, null, default, default,
|
||||
Array.Empty<VoteParticipantDto>(),
|
||||
Array.Empty<RescheduleOptionDto>(),
|
||||
Array.Empty<RescheduleOptionVoteDto>());
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||
SET option_id = EXCLUDED.option_id,
|
||||
voted_at = now()
|
||||
""",
|
||||
new
|
||||
{
|
||||
ProposalId = proposal.Id,
|
||||
PlayerId = playerId.Value,
|
||||
command.OptionId
|
||||
},
|
||||
transaction);
|
||||
|
||||
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 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.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.Id },
|
||||
transaction)).ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
return new HandleRescheduleVoteResult(
|
||||
true,
|
||||
"Ваш голос учтён. До дедлайна его можно изменить.",
|
||||
proposal.Id,
|
||||
proposal.SessionId,
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
proposal.VotingDeadlineAt,
|
||||
participants,
|
||||
options,
|
||||
votes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleVoteResult(
|
||||
bool Success,
|
||||
string? ReplyText,
|
||||
Guid? ProposalId,
|
||||
Guid? SessionId,
|
||||
string? Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
DateTimeOffset VotingDeadlineAt,
|
||||
IReadOnlyList<VoteParticipantDto> Participants,
|
||||
IReadOnlyList<RescheduleOptionDto> Options,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> Votes);
|
||||
Reference in New Issue
Block a user