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 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( """ 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( """ 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📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется."""; 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 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( "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( """ 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); } }