using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Microsoft.Extensions.Logging; using Npgsql; namespace GmRelay.Shared.Features.Sessions.CreateSession; public sealed record LeaveSessionCommand( Guid SessionId, PlatformUser User, string InteractionId, PlatformGroup Group, PlatformMessageRef ScheduleMessage); internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string DisplayName); public sealed class LeaveSessionHandler( NpgsqlDataSource dataSource, IPlatformMessenger messenger, IScheduleMessageUpdateLock scheduleUpdateLock, ILogger logger) { public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) { await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct); await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); var transactionCommitted = false; try { var session = await connection.QuerySingleOrDefaultAsync( """ SELECT title AS Title, batch_id AS BatchId, status AS Status, max_players AS MaxPlayers FROM sessions WHERE id = @SessionId FOR UPDATE """, new { command.SessionId }, transaction); if (session is null) { await transaction.RollbackAsync(ct); await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return; } if (SessionStatus.IsCancelled(session.Status)) { await transaction.RollbackAsync(ct); await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return; } var platform = command.User.Platform.ToString(); var participant = await connection.QuerySingleOrDefaultAsync( """ SELECT sp.id AS ParticipantRowId, p.display_name AS DisplayName, sp.registration_status AS RegistrationStatus 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 FOR UPDATE OF sp """, new { command.SessionId, Platform = platform, command.User.ExternalUserId }, transaction); if (participant is null) { await transaction.RollbackAsync(ct); await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct); return; } await connection.ExecuteAsync( """ DELETE FROM session_participants WHERE id = @ParticipantRowId """, new { participant.ParticipantRowId }, transaction); var activeParticipantsAfterLeave = await connection.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active """, new { command.SessionId, Active = ParticipantRegistrationStatus.Active }, transaction); var waitlistedParticipants = await connection.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Waitlisted """, new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, transaction); string? promotedDisplayName = null; if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves( participant.RegistrationStatus, session.MaxPlayers, activeParticipantsAfterLeave, waitlistedParticipants)) { var promoted = await connection.QuerySingleAsync( """ SELECT sp.id AS ParticipantRowId, p.display_name AS DisplayName 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 = @Waitlisted ORDER BY sp.created_at ASC, sp.id ASC LIMIT 1 FOR UPDATE OF sp """, new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, transaction); await connection.ExecuteAsync( """ UPDATE session_participants SET registration_status = @Active, rsvp_status = @Pending, responded_at = NULL WHERE id = @ParticipantRowId """, new { promoted.ParticipantRowId, Active = ParticipantRegistrationStatus.Active, Pending = RsvpStatus.Pending }, transaction); promotedDisplayName = promoted.DisplayName; } 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 { session.BatchId }, transaction)).ToList(); var batchParticipants = (await connection.QueryAsync( """ SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_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 { session.BatchId }, transaction)).ToList(); await transaction.CommitAsync(ct); transactionCommitted = true; var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( command.Group, view, command.ScheduleMessage), ct); var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Вы удалены из листа ожидания." : promotedDisplayName is null ? "Вы отписались от сессии." : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; await AnswerAsync(command.InteractionId, callbackText, ct); } catch (Exception ex) { logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId); if (!transactionCommitted) { await transaction.RollbackAsync(ct); } var errorText = transactionCommitted ? "Запись снята, но не удалось обновить сообщение расписания." : "Произошла ошибка при отмене записи."; await AnswerAsync(command.InteractionId, errorText, ct); } } private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); }