using System.Globalization; using Dapper; using Npgsql; using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Microsoft.Extensions.Logging; namespace GmRelay.Shared.Features.Sessions.CreateSession; public sealed record JoinSessionCommand( Guid SessionId, PlatformUser User, string InteractionId, PlatformGroup Group, PlatformMessageRef ScheduleMessage); // DTOs for AOT compilation internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers); public sealed class JoinSessionHandler( NpgsqlDataSource dataSource, IPlatformMessenger messenger, IScheduleMessageUpdateLock scheduleUpdateLock, ILogger logger) { public async Task HandleAsync(JoinSessionCommand 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 { // 1. Убеждаемся, что игрок есть в базе var platform = command.User.Platform.ToString(); var legacyTelegramId = command.User.Platform == PlatformKind.Telegram ? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture) : (long?)null; var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram ? command.User.ExternalUsername : null; var playerId = await connection.ExecuteScalarAsync( @"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername) ON CONFLICT (platform, external_user_id) WHERE platform IS NOT NULL AND external_user_id IS NOT NULL DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username), platform = EXCLUDED.platform, external_user_id = EXCLUDED.external_user_id, external_username = EXCLUDED.external_username RETURNING id;", new { LegacyTelegramId = legacyTelegramId, Name = command.User.DisplayName, LegacyTelegramUsername = legacyTelegramUsername, Platform = platform, command.User.ExternalUserId, command.User.ExternalUsername }, transaction); // 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав. var batchInfo = await connection.QuerySingleOrDefaultAsync( @"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers FROM sessions WHERE id = @SessionId FOR UPDATE", new { command.SessionId }, transaction); if (batchInfo is null) { await transaction.RollbackAsync(ct); await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return; } if (SessionStatus.IsCancelled(batchInfo.Status)) { await transaction.RollbackAsync(ct); await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return; } var existingRegistrationStatus = await connection.ExecuteScalarAsync( """ SELECT sp.registration_status FROM session_participants sp WHERE sp.session_id = @SessionId AND sp.player_id = @PlayerId AND sp.is_gm = false """, new { command.SessionId, PlayerId = playerId }, transaction); if (existingRegistrationStatus is not null) { await transaction.RollbackAsync(ct); var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Вы уже в листе ожидания!" : "Вы уже записаны!"; await AnswerAsync(command.InteractionId, alreadyText, ct); return; } var activeParticipants = 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 registrationStatus = SessionCapacityRules.DecideJoinStatus(batchInfo.MaxPlayers, activeParticipants); // 3. Добавляем в основной состав или лист ожидания. RSVP остается Pending до финального подтверждения. var inserted = await connection.ExecuteAsync( @"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status) VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus) ON CONFLICT (session_id, player_id) DO NOTHING;", new { command.SessionId, PlayerId = playerId, Pending = RsvpStatus.Pending, RegistrationStatus = registrationStatus }, transaction); if (inserted == 0) { await transaction.RollbackAsync(ct); await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct); return; } // Загружаем весь батч для перерисовки 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 { BatchId = batchInfo.BatchId }, transaction); 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 { BatchId = batchInfo.BatchId }, transaction); await transaction.CommitAsync(ct); transactionCommitted = true; // 4. Перерисовываем сообщение var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( command.Group, view, command.ScheduleMessage), ct); var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Основной состав заполнен. Вы добавлены в лист ожидания." : "Вы успешно записаны!"; await AnswerAsync(command.InteractionId, callbackText, ct); } catch (Exception ex) { logger.LogError(ex, "Ошибка при добавлении игрока к сессии"); 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); }