using System.Globalization; using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Notifications; using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging; using Npgsql; namespace GmRelay.Shared.Features.Confirmation.HandleRsvp; public sealed record HandleRsvpCommand( Guid SessionId, PlatformUser User, string Status, string InteractionId, PlatformGroup Group, PlatformMessageRef ConfirmationMessage); internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); internal sealed record RsvpSessionContext( Guid GroupId, string Title, DateTime ScheduledAt, string Status); internal sealed record ParticipantRsvpRow( string Platform, string ExternalUserId, string DisplayName, string? ExternalUsername, string RsvpStatus, string RegistrationStatus, bool IsGm); internal sealed record RsvpRecipientRow( string Platform, string ExternalUserId, string DisplayName, string? ExternalUsername); public sealed class HandleRsvpHandler( NpgsqlDataSource dataSource, IPlatformMessenger messenger, ILogger logger) { public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); var participantExists = await connection.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND COALESCE(p.platform, 'Telegram') = @Platform AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId AND sp.is_gm = false AND sp.registration_status = @Active ) """, new { command.SessionId, Platform = command.User.Platform.ToString(), command.User.ExternalUserId, Active = ParticipantRegistrationStatus.Active }, transaction); if (!participantExists) { await messenger.AnswerInteractionAsync( new PlatformInteractionReply( command.InteractionId, "Вы не являетесь участником этой сессии."), ct); return; } var updated = await connection.ExecuteAsync( """ UPDATE session_participants SET rsvp_status = @Status, responded_at = now() WHERE session_id = @SessionId AND player_id = ( SELECT id FROM players WHERE COALESCE(platform, 'Telegram') = @Platform AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId LIMIT 1 ) AND registration_status = @Active AND rsvp_status != @Status """, new { command.SessionId, command.Status, Platform = command.User.Platform.ToString(), command.User.ExternalUserId, Active = ParticipantRegistrationStatus.Active }, transaction); if (updated == 0) { var alreadyText = command.Status == RsvpStatus.Confirmed ? "Вы уже подтвердили участие." : "Вы уже отказались от участия."; await messenger.AnswerInteractionAsync( new PlatformInteractionReply(command.InteractionId, alreadyText), ct); return; } var session = await connection.QuerySingleAsync( """ SELECT s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status AS Status FROM sessions s WHERE s.id = @SessionId """, new { command.SessionId }, transaction); if (command.Status == RsvpStatus.Declined) { var decision = RsvpFlowRules.Evaluate( command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0); if (decision.ShouldRevertSessionToConfirmationSent) { await connection.ExecuteAsync( """ UPDATE sessions SET status = @ConfirmationSent, updated_at = now() WHERE id = @SessionId AND status = @Confirmed """, new { command.SessionId, ConfirmationSent = SessionStatus.ConfirmationSent, Confirmed = SessionStatus.Confirmed }, transaction); } var gmRecipients = (await GetGmRecipientsAsync(connection, session.GroupId, transaction)) .ToList(); await transaction.CommitAsync(ct); if (gmRecipients.Count > 0) { await messenger.SendRsvpOutcomeAsync( new PlatformRsvpOutcomeNotification( PlatformRsvpOutcomeKind.GmPlayerDeclined, Group: null, gmRecipients, command.SessionId, session.Title, session.ScheduledAt, ActorDisplayName: command.User.DisplayName), ct); } await messenger.AnswerInteractionAsync( new PlatformInteractionReply(command.InteractionId, decision.CallbackText), ct); } else { var counts = await connection.QuerySingleAsync( """ SELECT count(*) AS Total, count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active """, new { command.SessionId, Confirmed = RsvpStatus.Confirmed, Declined = RsvpStatus.Declined, Active = ParticipantRegistrationStatus.Active }, transaction); var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed); if (decision.ShouldMarkSessionConfirmed) { await connection.ExecuteAsync( """ UPDATE sessions SET status = @Confirmed, updated_at = now() WHERE id = @SessionId """, new { command.SessionId, Confirmed = SessionStatus.Confirmed }, transaction); } var gmRecipients = decision.ShouldNotifyGm ? (await GetGmRecipientsAsync(connection, session.GroupId, transaction)).ToList() : []; await transaction.CommitAsync(ct); if (decision.ShouldNotifyGroup) { await messenger.SendRsvpOutcomeAsync( new PlatformRsvpOutcomeNotification( PlatformRsvpOutcomeKind.GroupAllConfirmed, command.Group, [], command.SessionId, session.Title, session.ScheduledAt), ct); } if (decision.ShouldNotifyGm && gmRecipients.Count > 0) { await messenger.SendRsvpOutcomeAsync( new PlatformRsvpOutcomeNotification( PlatformRsvpOutcomeKind.GmAllConfirmed, Group: null, gmRecipients, command.SessionId, session.Title, session.ScheduledAt), ct); } await messenger.AnswerInteractionAsync( new PlatformInteractionReply(command.InteractionId, decision.CallbackText), ct); } await UpdateConfirmationMessage(command, session, ct); } private async Task UpdateConfirmationMessage( HandleRsvpCommand command, RsvpSessionContext session, CancellationToken ct) { try { await using var connection = await dataSource.OpenConnectionAsync(ct); var participants = (await connection.QueryAsync( """ SELECT COALESCE(p.platform, 'Telegram') AS Platform, COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, sp.rsvp_status AS RsvpStatus, sp.registration_status AS RegistrationStatus, sp.is_gm AS IsGm 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 sp.responded_at NULLS LAST """, new { command.SessionId, Active = ParticipantRegistrationStatus.Active })) .Select(ToParticipant) .ToList(); var disableActions = participants.Count > 0 && participants.All(participant => participant.RsvpStatus == RsvpStatus.Confirmed); await messenger.UpdateConfirmationRequestAsync( new PlatformRsvpMessageUpdate( new PlatformConfirmationRequest( command.Group, command.SessionId, session.Title, session.ScheduledAt, participants, command.ConfirmationMessage), disableActions), ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId); } } private static async Task> GetGmRecipientsAsync( NpgsqlConnection connection, Guid groupId, NpgsqlTransaction transaction) { var rows = await connection.QueryAsync( """ SELECT DISTINCT COALESCE(p.platform, 'Telegram') AS Platform, COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId UNION SELECT DISTINCT COALESCE(p.platform, 'Telegram') AS Platform, COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername FROM game_groups g JOIN players p ON p.telegram_id = g.gm_telegram_id WHERE g.id = @GroupId AND g.gm_telegram_id IS NOT NULL """, new { GroupId = groupId }, transaction); return rows.Select(row => new PlatformUser( ParsePlatform(row.Platform), row.ExternalUserId, row.DisplayName, row.ExternalUsername)); } private static PlatformSessionParticipant ToParticipant(ParticipantRsvpRow row) => new( new PlatformUser( ParsePlatform(row.Platform), row.ExternalUserId, row.DisplayName, row.ExternalUsername), row.RsvpStatus, row.RegistrationStatus, row.IsGm); private static PlatformKind ParsePlatform(string platform) => Enum.Parse(platform, ignoreCase: true); }