From 9c91057798a6d5e8c4f4f611a77eaa18ff264338 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Fri, 24 Apr 2026 13:28:01 +0300 Subject: [PATCH] feat: add session capacity waitlist --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 5 + compose.yaml | 4 +- .../HandleRsvp/HandleRsvpHandler.cs | 16 +- .../SendConfirmationHandler.cs | 6 +- .../SendJoinLink/SendJoinLinkHandler.cs | 8 +- .../CreateSession/CancelSessionHandler.cs | 12 +- .../CreateSession/CreateSessionHandler.cs | 17 +- .../CreateSession/JoinSessionHandler.cs | 102 +++++++-- .../CreateSession/NewSessionCommandParser.cs | 41 +++- .../PromoteWaitlistedPlayerHandler.cs | 185 ++++++++++++++++ .../ListSessions/DeleteSessionHandler.cs | 21 +- .../ListSessions/ListSessionsHandler.cs | 23 +- .../HandleRescheduleTimeInputHandler.cs | 15 +- .../HandleRescheduleVoteHandler.cs | 19 +- .../Infrastructure/Telegram/UpdateRouter.cs | 15 ++ .../V006__add_session_capacity_waitlist.sql | 16 ++ src/GmRelay.Bot/Program.cs | 1 + .../GmRelay.ServiceDefaults.csproj | 10 +- .../Domain/ParticipantRegistrationStatus.cs | 9 + .../Domain/SessionCapacityRules.cs | 26 +++ .../Rendering/SessionBatchRenderer.cs | 39 +++- .../Components/Layout/NavMenu.razor | 2 +- .../Components/Pages/EditSession.razor | 10 +- .../Components/Pages/GroupDetails.razor | 77 ++++++- .../Services/AuthorizedSessionService.cs | 15 +- src/GmRelay.Web/Services/ISessionStore.cs | 3 +- src/GmRelay.Web/Services/SessionService.cs | 197 ++++++++++++++++-- src/GmRelay.Web/wwwroot/app.css | 4 +- .../NewSessionCommandParserTests.cs | 20 ++ .../SessionCapacityRulesTests.cs | 31 +++ .../Rendering/SessionBatchRendererTests.cs | 18 +- .../Web/AuthorizedSessionServiceTests.cs | 54 ++++- 34 files changed, 915 insertions(+), 110 deletions(-) create mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs create mode 100644 src/GmRelay.Bot/Migrations/V006__add_session_capacity_waitlist.sql create mode 100644 src/GmRelay.Shared/Domain/ParticipantRegistrationStatus.cs create mode 100644 src/GmRelay.Shared/Domain/SessionCapacityRules.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index dc8aa28..7e09c1a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.1.5 + VERSION: 1.2.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 11fb28f..befb829 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.1.5 + 1.2.0 net10.0 preview enable diff --git a/README.md b/README.md index 0e4d48f..8bda147 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ ### 🤖 Telegram Бот - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). - **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки. +- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию и автоматически ведёт очередь ожидания. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. @@ -19,6 +20,7 @@ ### 🌐 Web Dashboard (Blazor Server) - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. +- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. - **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC. @@ -110,9 +112,12 @@ docker compose up -d Название: Легенды Берега Мечей (D&D 5e) Время: 15.05.2024 19:30 Время: 22.05.2024 19:00 +Мест: 4 Ссылка: https://discord.gg/invite-link ``` +Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. + ### Другие команды - `/listsessions` — Показать список всех актуальных игр в этой группе. - `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. diff --git a/compose.yaml b/compose.yaml index 05e9fbc..1e36802 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.5 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.2.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.5 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.2.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs index 578eb39..7f231c5 100644 --- a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs @@ -48,9 +48,10 @@ public sealed class HandleRsvpHandler( WHERE sp.session_id = @SessionId AND p.telegram_id = @TelegramUserId AND sp.is_gm = false + AND sp.registration_status = @Active ) """, - new { command.SessionId, command.TelegramUserId }, + new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active }, transaction); if (!participantExists) @@ -69,9 +70,10 @@ public sealed class HandleRsvpHandler( responded_at = now() WHERE session_id = @SessionId AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId) + AND registration_status = @Active AND rsvp_status != @Status """, - new { command.SessionId, command.TelegramUserId, command.Status }, + new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active }, transaction); if (updated == 0) @@ -156,12 +158,14 @@ public sealed class HandleRsvpHandler( 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 + Declined = RsvpStatus.Declined, + Active = ParticipantRegistrationStatus.Active }, transaction); @@ -234,10 +238,12 @@ public sealed class HandleRsvpHandler( sp.rsvp_status AS RsvpStatus FROM session_participants sp JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false + 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 })).ToList(); + new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList(); var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList(); diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs index 69f0299..72b6948 100644 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -60,9 +60,11 @@ public sealed class SendConfirmationHandler( p.telegram_username AS TelegramUsername FROM session_participants sp JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active """, - new { SessionId = sessionId })).ToList(); + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); if (participants.Count == 0) { diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs index 557089b..1db244c 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -63,8 +63,14 @@ public sealed class SendJoinLinkHandler( JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND sp.rsvp_status = @Confirmed + AND sp.registration_status = @Active """, - new { SessionId = sessionId, Confirmed = RsvpStatus.Confirmed })).ToList(); + new + { + SessionId = sessionId, + Confirmed = RsvpStatus.Confirmed, + Active = ParticipantRegistrationStatus.Active + })).ToList(); // 3. Build message with player mentions var mentions = string.Join(", ", players.Select(p => diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 8e50505..937a7fc 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -55,16 +55,22 @@ public sealed class CancelSessionHandler( // 3. Загружаем весь батч для перерисовки var batchSessions = await connection.QueryAsync( - @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers + FROM sessions + WHERE batch_id = @BatchId + ORDER BY scheduled_at", new { BatchId = session.BatchId }, transaction); var batchParticipants = await connection.QueryAsync( - @"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername + @"SELECT sp.session_id as SessionId, + p.display_name as DisplayName, + 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.responded_at ASC, p.created_at ASC", + ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", new { BatchId = session.BatchId }, transaction); await transaction.CommitAsync(ct); diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 63f8db8..6eaee43 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -32,11 +32,19 @@ public sealed class CreateSessionHandler( cancellationToken: cancellationToken); } + foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs) + { + await botClient.SendMessage( + message.Chat.Id, + $"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.", + cancellationToken: cancellationToken); + } + if (!parseResult.IsValid) { await botClient.SendMessage( chatId: message.Chat.Id, - text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nСсылка: https://link", + text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link", cancellationToken: cancellationToken); return; } @@ -93,8 +101,8 @@ public sealed class CreateSessionHandler( { var sessionId = await connection.ExecuteScalarAsync( """ - INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId) + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players) + VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers) RETURNING id; """, new @@ -105,11 +113,12 @@ public sealed class CreateSessionHandler( Link = link, ScheduledAt = scheduledAt, ThreadId = messageThreadId, + MaxPlayers = parseResult.MaxPlayers, Status = SessionStatus.Planned }, transaction); - sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned)); + sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers)); } await transaction.CommitAsync(cancellationToken); diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs index fa7963c..cadfba1 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -2,7 +2,6 @@ using Dapper; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; @@ -18,7 +17,7 @@ public sealed record JoinSessionCommand( int MessageId); // DTOs for AOT compilation -internal sealed record JoinSessionBatchDto(Guid BatchId, string Title); +internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers); public sealed class JoinSessionHandler( NpgsqlDataSource dataSource, @@ -29,6 +28,7 @@ public sealed class JoinSessionHandler( { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); + var transactionCommitted = false; try { @@ -41,12 +41,68 @@ public sealed class JoinSessionHandler( new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername }, transaction); - // 2. Добавляем в участники сессии (статус Pending, так как за 24 часа нужно будет финальное подтверждение) + // 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав. + var batchInfo = await connection.QuerySingleOrDefaultAsync( + @"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers + FROM sessions + WHERE id = @SessionId + FOR UPDATE", + new { command.SessionId }, + transaction); + + if (batchInfo is null) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: 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 bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: 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) - VALUES (@SessionId, @PlayerId, false, 'Pending') + @"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 { SessionId = command.SessionId, PlayerId = playerId }, + new + { + command.SessionId, + PlayerId = playerId, + Pending = RsvpStatus.Pending, + RegistrationStatus = registrationStatus + }, transaction); if (inserted == 0) @@ -56,26 +112,28 @@ public sealed class JoinSessionHandler( return; } - // 3. Получаем batch_id по session_id - var batchInfo = await connection.QuerySingleAsync( - @"SELECT batch_id as BatchId, title as Title FROM sessions WHERE id = @SessionId", - new { command.SessionId }, transaction); - // Загружаем весь батч для перерисовки var batchSessions = await connection.QueryAsync( - @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers + 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, p.telegram_username as TelegramUsername + @"SELECT sp.session_id as SessionId, + p.display_name as DisplayName, + 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.responded_at ASC, p.created_at ASC", + 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 renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); @@ -88,13 +146,23 @@ public sealed class JoinSessionHandler( replyMarkup: renderResult.Markup, cancellationToken: ct); - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы успешно записаны!", cancellationToken: ct); + var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted + ? "Основной состав заполнен. Вы добавлены в лист ожидания." + : "Вы успешно записаны!"; + await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct); } catch (Exception ex) { logger.LogError(ex, "Ошибка при добавлении игрока к сессии"); - await transaction.RollbackAsync(ct); - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct); + if (!transactionCommitted) + { + await transaction.RollbackAsync(ct); + } + + var errorText = transactionCommitted + ? "Регистрация сохранена, но не удалось обновить сообщение расписания." + : "Произошла ошибка при регистрации."; + await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct); } } } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs index 8bb593f..21f151b 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs @@ -5,14 +5,17 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession; internal sealed record NewSessionParseResult( string? Title, string? Link, + int? MaxPlayers, IReadOnlyList ScheduledTimes, IReadOnlyList PastTimeInputs, - IReadOnlyList InvalidTimeInputs) + IReadOnlyList InvalidTimeInputs, + IReadOnlyList InvalidSeatLimitInputs) { public bool IsValid => !string.IsNullOrWhiteSpace(Title) && !string.IsNullOrWhiteSpace(Link) && - ScheduledTimes.Count > 0; + ScheduledTimes.Count > 0 && + InvalidSeatLimitInputs.Count == 0; } internal static class NewSessionCommandParser @@ -20,14 +23,22 @@ internal static class NewSessionCommandParser private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:"; private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; + private static readonly string[] SeatLimitPrefixes = + [ + "\u041c\u0435\u0441\u0442:", + "\u041b\u0438\u043c\u0438\u0442:", + "\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:" + ]; public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc) { string? title = null; string? link = null; + int? maxPlayers = null; var scheduledTimes = new List(); var pastTimeInputs = new List(); var invalidTimeInputs = new List(); + var invalidSeatLimitInputs = new List(); foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) { @@ -43,6 +54,23 @@ internal static class NewSessionCommandParser continue; } + var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix => + line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (seatLimitPrefix is not null) + { + var seatLimitInput = line[seatLimitPrefix.Length..].Trim(); + if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0) + { + maxPlayers = parsedMaxPlayers; + } + else + { + invalidSeatLimitInputs.Add(seatLimitInput); + } + + continue; + } + if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase)) { continue; @@ -64,6 +92,13 @@ internal static class NewSessionCommandParser scheduledTimes.Add(scheduledAt); } - return new NewSessionParseResult(title, link, scheduledTimes, pastTimeInputs, invalidTimeInputs); + return new NewSessionParseResult( + title, + link, + maxPlayers, + scheduledTimes, + pastTimeInputs, + invalidTimeInputs, + invalidSeatLimitInputs); } } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs new file mode 100644 index 0000000..2439cc8 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -0,0 +1,185 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using Npgsql; +using Telegram.Bot; + +namespace GmRelay.Bot.Features.Sessions.CreateSession; + +public sealed record PromoteWaitlistedPlayerCommand( + Guid SessionId, + long TelegramUserId, + string CallbackQueryId, + long ChatId, + int MessageId); + +internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers); +internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName); + +public sealed class PromoteWaitlistedPlayerHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger logger) +{ + public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken 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 s.title AS Title, + s.batch_id AS BatchId, + s.max_players AS MaxPlayers, + g.gm_telegram_id AS GmId + FROM sessions s + JOIN game_groups g ON s.group_id = g.id + WHERE s.id = @SessionId + FOR UPDATE + """, + new { command.SessionId }, + transaction); + + if (session is null) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); + return; + } + + if (session.GmId != command.TelegramUserId) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: 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 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); + + if (waitlistedParticipants == 0) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct); + return; + } + + if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants)) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct); + return; + } + + 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); + + var batchSessions = (await connection.QueryAsync( + """ + SELECT id AS SessionId, + scheduled_at AS ScheduledAt, + status AS Status, + max_players AS MaxPlayers + 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, + 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 renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); + + await bot.EditMessageText( + chatId: command.ChatId, + messageId: command.MessageId, + text: renderResult.Text, + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + replyMarkup: renderResult.Markup, + cancellationToken: ct); + + await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при повышении игрока из листа ожидания для сессии {SessionId}", command.SessionId); + if (!transactionCommitted) + { + await transaction.RollbackAsync(ct); + } + + var errorText = transactionCommitted + ? "Игрок повышен, но не удалось обновить сообщение расписания." + : "Ошибка при обновлении листа ожидания."; + await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs index d48d057..be219fc 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -74,16 +74,23 @@ public sealed class DeleteSessionHandler( // A simple way is to re-render the list: await using var readConnection = await dataSource.OpenConnectionAsync(ct); var sessions = await readConnection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount, + @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, + COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, + COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, g.gm_telegram_id as GmId FROM sessions s JOIN game_groups g ON s.group_id = g.id LEFT JOIN session_participants sp ON s.id = sp.session_id WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() - GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id + GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id ORDER BY s.scheduled_at ASC", - new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled }); + new + { + ChatId = command.ChatId, + Cancelled = SessionStatus.Cancelled, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted + }); var sessionsList = sessions.ToList(); @@ -96,7 +103,11 @@ public sealed class DeleteSessionHandler( var text = "📅 Ближайшие игры:\n\n"; foreach (var s in sessionsList) { - text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n"; + var seats = s.MaxPlayers.HasValue + ? $"{s.PlayerCount}/{s.MaxPlayers.Value}" + : s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty; + text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; } var isGm = command.TelegramUserId == sessionsList.First().GmId; diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs index a3b9bea..fd2c00d 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs @@ -6,7 +6,7 @@ using Telegram.Bot.Types; namespace GmRelay.Bot.Features.Sessions.ListSessions; -internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int PlayerCount, long GmId); +internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId); public sealed class ListSessionsHandler( NpgsqlDataSource dataSource, @@ -17,16 +17,23 @@ public sealed class ListSessionsHandler( await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); var sessions = await connection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount, + @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, + COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, + COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, g.gm_telegram_id as GmId FROM sessions s JOIN game_groups g ON s.group_id = g.id LEFT JOIN session_participants sp ON s.id = sp.session_id WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() - GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id + GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id ORDER BY s.scheduled_at ASC", - new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled }); + new + { + ChatId = message.Chat.Id, + Cancelled = SessionStatus.Cancelled, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted + }); var sessionsList = sessions.ToList(); @@ -42,7 +49,11 @@ public sealed class ListSessionsHandler( var text = "📅 Ближайшие игры:\n\n"; foreach (var s in sessionsList) { - text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n"; + var seats = s.MaxPlayers.HasValue + ? $"{s.PlayerCount}/{s.MaxPlayers.Value}" + : s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty; + text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; } var isGm = message.From?.Id == sessionsList.First().GmId; diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index 82498c4..b8c2c25 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -89,9 +89,11 @@ public sealed class HandleRescheduleTimeInputHandler( SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername FROM session_participants sp JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active """, - new { proposal.SessionId })).ToList(); + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); // 4. If no participants — reschedule immediately if (participants.Count == 0) @@ -214,17 +216,20 @@ public sealed class HandleRescheduleTimeInputHandler( await using var conn = await dataSource.OpenConnectionAsync(ct); var batchSessions = (await conn.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { proposal.BatchId })).ToList(); var batchParticipants = (await conn.QueryAsync( """ - SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername + SELECT sp.session_id AS SessionId, + p.display_name AS DisplayName, + 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.responded_at ASC, p.created_at ASC + ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC """, new { proposal.BatchId })).ToList(); diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index 07f0bd7..aaccb6e 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -74,8 +74,9 @@ public sealed class HandleRescheduleVoteHandler( WHERE sp.session_id = @SessionId AND p.telegram_id = @TelegramUserId AND sp.is_gm = false + AND sp.registration_status = @Active """, - new { proposal.SessionId, command.TelegramUserId }, + new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active }, transaction); if (playerId is null) @@ -107,9 +108,11 @@ public sealed class HandleRescheduleVoteHandler( p.telegram_username AS TelegramUsername FROM session_participants sp JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active """, - new { proposal.SessionId }, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, transaction)).ToList(); var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) @@ -187,8 +190,9 @@ public sealed class HandleRescheduleVoteHandler( SET rsvp_status = 'Pending', responded_at = NULL WHERE session_id = @SessionId AND is_gm = false + AND registration_status = @Active """, - new { proposal.SessionId }, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, transaction); } @@ -260,19 +264,20 @@ public sealed class HandleRescheduleVoteHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); var batchSessions = (await connection.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers 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.telegram_username AS TelegramUsername + 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.responded_at ASC, p.created_at ASC + ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC """, new { proposal.BatchId })).ToList(); diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 3a8fc12..e4817d9 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -20,6 +20,7 @@ public sealed class UpdateRouter( HandleRsvpHandler rsvpHandler, CreateSessionHandler createSessionHandler, JoinSessionHandler joinSessionHandler, + PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, CancelSessionHandler cancelSessionHandler, DeleteSessionHandler deleteSessionHandler, ListSessionsHandler listSessionsHandler, @@ -85,6 +86,19 @@ public sealed class UpdateRouter( return; } + if (action == "promote_waitlist" && parts.Length >= 2 && Guid.TryParse(parts[1], out var promoteSessionId)) + { + var command = new PromoteWaitlistedPlayerCommand( + SessionId: promoteSessionId, + TelegramUserId: query.From.Id, + CallbackQueryId: query.Id, + ChatId: message.Chat.Id, + MessageId: message.MessageId); + + await promoteWaitlistedPlayerHandler.HandleAsync(command, ct); + return; + } + if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId)) { var command = new DeleteSessionCommand( @@ -192,6 +206,7 @@ public sealed class UpdateRouter( /newsession Название: My Game Время: 15.05.2026 19:30 + Мест: 4 Ссылка: https://link /listsessions — список предстоящих сессий diff --git a/src/GmRelay.Bot/Migrations/V006__add_session_capacity_waitlist.sql b/src/GmRelay.Bot/Migrations/V006__add_session_capacity_waitlist.sql new file mode 100644 index 0000000..39b4880 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V006__add_session_capacity_waitlist.sql @@ -0,0 +1,16 @@ +-- Add per-session seat limits and participant waitlist support. +ALTER TABLE sessions + ADD COLUMN max_players INTEGER, + ADD CONSTRAINT ck_sessions_max_players CHECK (max_players IS NULL OR max_players > 0); + +ALTER TABLE session_participants + ADD COLUMN registration_status VARCHAR(50) NOT NULL DEFAULT 'Active' + CHECK (registration_status IN ('Active', 'Waitlisted')), + ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +CREATE INDEX ix_session_participants_session_registration_status + ON session_participants (session_id, registration_status); + +CREATE INDEX ix_session_participants_waitlist_order + ON session_participants (session_id, created_at, id) + WHERE registration_status = 'Waitlisted'; diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index d71cd0d..188b51d 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -54,6 +54,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj b/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj index b71d3c7..4502460 100644 --- a/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj +++ b/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj @@ -12,11 +12,11 @@ - - - - - + + + + + diff --git a/src/GmRelay.Shared/Domain/ParticipantRegistrationStatus.cs b/src/GmRelay.Shared/Domain/ParticipantRegistrationStatus.cs new file mode 100644 index 0000000..9583b26 --- /dev/null +++ b/src/GmRelay.Shared/Domain/ParticipantRegistrationStatus.cs @@ -0,0 +1,9 @@ +namespace GmRelay.Shared.Domain; + +public static class ParticipantRegistrationStatus +{ + public const string Active = "Active"; + public const string Waitlisted = "Waitlisted"; + + public static readonly string[] All = [Active, Waitlisted]; +} diff --git a/src/GmRelay.Shared/Domain/SessionCapacityRules.cs b/src/GmRelay.Shared/Domain/SessionCapacityRules.cs new file mode 100644 index 0000000..6347256 --- /dev/null +++ b/src/GmRelay.Shared/Domain/SessionCapacityRules.cs @@ -0,0 +1,26 @@ +namespace GmRelay.Shared.Domain; + +public static class SessionCapacityRules +{ + public static string DecideJoinStatus(int? maxPlayers, int activeParticipants) + { + if (!maxPlayers.HasValue) + { + return ParticipantRegistrationStatus.Active; + } + + return activeParticipants < maxPlayers.Value + ? ParticipantRegistrationStatus.Active + : ParticipantRegistrationStatus.Waitlisted; + } + + public static bool CanPromoteWaitlistedPlayer(int? maxPlayers, int activeParticipants, int waitlistedParticipants) + { + if (waitlistedParticipants <= 0) + { + return false; + } + + return !maxPlayers.HasValue || activeParticipants < maxPlayers.Value; + } +} diff --git a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs index a83c036..8f58b04 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs @@ -3,8 +3,8 @@ using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Shared.Rendering; -public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status); -public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername); +public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers); +public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus); public static class SessionBatchRenderer { @@ -22,10 +22,17 @@ public static class SessionBatchRenderer foreach (var session in activeSessions) { - var sessionPlayers = participants.Where(p => p.SessionId == session.SessionId).ToList(); + var sessionPlayers = participants + .Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active) + .ToList(); + var waitlistedPlayers = participants + .Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted) + .ToList(); messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\n"; - messageText += $"👥 Игроки ({sessionPlayers.Count}):\n"; + messageText += session.MaxPlayers.HasValue + ? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n" + : $"👥 Игроки ({sessionPlayers.Count}):\n"; if (sessionPlayers.Count > 0) { @@ -36,6 +43,12 @@ public static class SessionBatchRenderer messageText += " Пока никто не записался\n"; } + if (waitlistedPlayers.Count > 0) + { + messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n"; + messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; + } + if (SessionStatus.IsCancelled(session.Status)) { messageText += "❌ Сессия отменена\n\n"; @@ -46,13 +59,27 @@ public static class SessionBatchRenderer var dateTitle = session.ScheduledAt.FormatMoscowShort(); buttons.Add(new[] { - InlineKeyboardButton.WithCallbackData($"✋ На {dateTitle}", $"join_session:{session.SessionId}"), + InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"), InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"), InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}") - }); + } + .Concat(SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, sessionPlayers.Count, waitlistedPlayers.Count) + ? [InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle} (ГМ)", $"promote_waitlist:{session.SessionId}")] + : []) + .ToArray()); } } return (messageText, new InlineKeyboardMarkup(buttons)); } + + private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle) + { + if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value) + { + return $"⏳ В лист ожидания {dateTitle}"; + } + + return $"✋ На {dateTitle}"; + } } diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 6431a0e..fd50abc 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -47,7 +47,7 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/EditSession.razor b/src/GmRelay.Web/Components/Pages/EditSession.razor index aa100fd..af68256 100644 --- a/src/GmRelay.Web/Components/Pages/EditSession.razor +++ b/src/GmRelay.Web/Components/Pages/EditSession.razor @@ -51,6 +51,12 @@ +
+ + +
Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.
+
+
+ @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + @if (sessions == null) {
@@ -48,6 +55,7 @@ Название Время (МСК) + Места Статус Ссылка Действие @@ -59,6 +67,7 @@ @session.Title @session.ScheduledAt.FormatMoscow() + @FormatSeats(session) @TranslateStatus(session.Status) @@ -69,9 +78,17 @@ - - ✏️ Изменить - +
+ + ✏️ Изменить + + @if (CanPromote(session)) + { + + } +
} @@ -93,6 +110,10 @@ 🕐 Время @session.ScheduledAt.FormatMoscow()
+
+ 👥 Места + @FormatSeats(session) +
🔗 Ссылка Подключиться ↗ @@ -102,6 +123,12 @@ ✏️ Изменить + @if (CanPromote(session)) + { + + }
} @@ -112,11 +139,14 @@ @code { [Parameter] public Guid GroupId { get; set; } private List? sessions; + private Guid? promotingSessionId; + private long telegramId; + private string? errorMessage; protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetTelegramId(out telegramId)) { Navigation.NavigateTo("/access-denied"); return; @@ -129,6 +159,45 @@ } } + private async Task PromoteWaitlisted(Guid sessionId) + { + errorMessage = null; + promotingSessionId = sessionId; + + try + { + await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId); + sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + promotingSessionId = null; + } + } + + private static bool CanPromote(WebSession session) => + session.WaitlistedPlayerCount > 0 && + (!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value); + + private static string FormatSeats(WebSession session) + { + var seats = session.MaxPlayers.HasValue + ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" + : session.ActivePlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return session.WaitlistedPlayerCount > 0 + ? $"{seats} · ожидание {session.WaitlistedPlayerCount}" + : seats; + } + private string GetStatusClass(string status) => status switch { SessionStatus.Confirmed => "status-success", diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 310030b..28c4b7b 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -26,7 +26,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; } - public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink) + public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { var session = await GetSessionForGmAsync(sessionId, gmId); if (session is null) @@ -34,7 +34,18 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) throw new SessionAccessDeniedException(sessionId, gmId); } - await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink); + await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers); + } + + public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId) + { + var session = await GetSessionForGmAsync(sessionId, gmId); + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, gmId); + } + + await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); } private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index 0d7510c..f9bb888 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -6,5 +6,6 @@ public interface ISessionStore Task GetGroupAsync(Guid groupId); Task> GetUpcomingSessionsAsync(Guid groupId); Task GetSessionAsync(Guid sessionId); - Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink); + Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers); + Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId); } diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index a5580cb..9250a94 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -7,7 +7,21 @@ using Telegram.Bot; namespace GmRelay.Web.Services; public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId); -public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime ScheduledAt, string Status, string JoinLink, Guid BatchId, int? BatchMessageId, long TelegramChatId); +public sealed record WebSession( + Guid Id, + Guid GroupId, + string Title, + DateTime ScheduledAt, + string Status, + string JoinLink, + Guid BatchId, + int? BatchMessageId, + long TelegramChatId, + int? MaxPlayers, + int ActivePlayerCount, + int WaitlistedPlayerCount); + +internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -36,12 +50,34 @@ public sealed class SessionService( return (await conn.QueryAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.max_players AS MaxPlayers, + COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Active + ) active_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Waitlisted + ) waitlist_counts ON true WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours' ORDER BY s.scheduled_at", - new { GroupId = groupId })).ToList(); + new + { + GroupId = groupId, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted + })).ToList(); } public async Task GetSessionAsync(Guid sessionId) @@ -50,14 +86,36 @@ public sealed class SessionService( return await conn.QuerySingleOrDefaultAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.max_players AS MaxPlayers, + COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Active + ) active_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Waitlisted + ) waitlist_counts ON true WHERE s.id = @SessionId", - new { SessionId = sessionId }); + new + { + SessionId = sessionId, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted + }); } - public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink) + public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); @@ -65,7 +123,10 @@ public sealed class SessionService( var oldSession = await conn.QuerySingleOrDefaultAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.max_players AS MaxPlayers, + 0 AS ActivePlayerCount, + 0 AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @Id AND s.group_id = @GroupId", @@ -82,9 +143,18 @@ public sealed class SessionService( SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, + max_players = @MaxPlayers, updated_at = now() WHERE id = @Id AND group_id = @GroupId", - new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink }, + new + { + Id = sessionId, + GroupId = groupId, + Title = title, + ScheduledAt = scheduledAt, + JoinLink = joinLink, + MaxPlayers = maxPlayers + }, transaction); if (updatedRows == 0) @@ -102,7 +172,9 @@ public sealed class SessionService( var timeChanged = oldSession.ScheduledAt != scheduledAt; var notification = $"🔄 Мастер обновил игру!\n\n" + $"📌 {System.Net.WebUtility.HtmlEncode(title)}\n" + - $"📅 Время: {scheduledAt.FormatMoscow()} (МСК)" + (timeChanged ? " (изменено)" : ""); + $"📅 Время: {scheduledAt.FormatMoscow()} (МСК)" + (timeChanged ? " (изменено)" : "") + + "\n" + + $"👥 Мест: {(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}"; await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); @@ -112,6 +184,104 @@ public sealed class SessionService( } } + public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var session = await conn.QuerySingleOrDefaultAsync( + @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, + s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, + g.telegram_chat_id AS TelegramChatId, + s.max_players AS MaxPlayers, + 0 AS ActivePlayerCount, + 0 AS WaitlistedPlayerCount + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.id = @SessionId AND s.group_id = @GroupId + FOR UPDATE", + new { SessionId = sessionId, GroupId = groupId }, + transaction); + + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, 0); + } + + var activeParticipants = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM session_participants + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Active + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, + transaction); + + var waitlistedParticipants = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM session_participants + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Waitlisted + """, + new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, + transaction); + + if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants)) + { + throw new InvalidOperationException(waitlistedParticipants == 0 + ? "Лист ожидания пуст." + : "Нет свободных мест для повышения игрока."); + } + + var promoted = await conn.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 { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, + transaction); + + await conn.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); + + await transaction.CommitAsync(); + + await bot.SendMessage( + session.TelegramChatId, + $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + + if (session.BatchMessageId.HasValue) + { + await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); + } + } + private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title) { try @@ -119,16 +289,19 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); var sessions = (await conn.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { BatchId = batchId })).ToList(); var participants = (await conn.QueryAsync( - @"SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername + @"SELECT sp.session_id AS SessionId, + p.display_name AS DisplayName, + 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.responded_at ASC, p.created_at ASC", + ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", new { BatchId = batchId })).ToList(); var renderResult = SessionBatchRenderer.Render(title, sessions, participants); diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 1165fab..0631ef1 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.1.0 + GM-Relay Design System v1.2.0 Dark RPG Dashboard Theme ============================================ */ @@ -821,4 +821,4 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator { .glass-card { padding: 1rem; } -} \ No newline at end of file +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs index 41fb803..344b330 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs @@ -13,6 +13,7 @@ public sealed class NewSessionCommandParserTests Название: Curse of Strahd Время: 24.04.2026 19:30 Время: 01.05.2026 20:00 + Мест: 4 Ссылка: https://example.test/room """; @@ -21,6 +22,7 @@ public sealed class NewSessionCommandParserTests Assert.True(result.IsValid); Assert.Equal("Curse of Strahd", result.Title); Assert.Equal("https://example.test/room", result.Link); + Assert.Equal(4, result.MaxPlayers); Assert.Equal( [ new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero), @@ -65,4 +67,22 @@ public sealed class NewSessionCommandParserTests Assert.False(result.IsValid); Assert.Null(result.Link); } + + [Fact] + public void Parse_ShouldCollectInvalidSeatLimit() + { + var text = """ + /newsession + Название: Blades in the Dark + Время: 25.04.2026 19:30 + Мест: 0 + Ссылка: https://example.test/blades + """; + + var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow); + + Assert.False(result.IsValid); + Assert.Null(result.MaxPlayers); + Assert.Equal(["0"], result.InvalidSeatLimitInputs); + } } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs new file mode 100644 index 0000000..d3d2638 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs @@ -0,0 +1,31 @@ +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class SessionCapacityRulesTests +{ + [Fact] + public void DecideJoinStatus_ShouldReturnActive_WhenSessionHasFreeSeats() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 2); + + Assert.Equal(ParticipantRegistrationStatus.Active, status); + } + + [Fact] + public void DecideJoinStatus_ShouldReturnWaitlisted_WhenSessionReachedLimit() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 2, activeParticipants: 2); + + Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); + } + + [Fact] + public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat() + { + Assert.True(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 1)); + Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 3, waitlistedParticipants: 1)); + Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 0)); + } +} diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs index 28f932e..6011e48 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs @@ -14,14 +14,15 @@ public sealed class SessionBatchRendererTests var sessions = new[] { - new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned), - new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled), - new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned) + new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4), + new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null), + new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2) }; var participants = new[] { - new ParticipantBatchDto(secondSessionId, "Alice", "alice"), - new ParticipantBatchDto(cancelledSessionId, "Bob", null) + new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active), + new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted), + new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active) }; var result = SessionBatchRenderer.Render("Campaign", sessions, participants); @@ -35,7 +36,11 @@ public sealed class SessionBatchRendererTests Assert.Contains("Campaign", text); Assert.True(firstIndex < secondIndex); Assert.True(secondIndex < thirdIndex); + Assert.Contains("Места: 0/2", text); + Assert.Contains("Места: 1/4", text); Assert.Contains("@alice", text); + Assert.Contains("Лист ожидания (1)", text); + Assert.Contains("Charlie", text); Assert.Contains("Bob", text); Assert.Equal(2, result.Markup.InlineKeyboard.Count()); Assert.Collection( @@ -45,6 +50,7 @@ public sealed class SessionBatchRendererTests callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), - callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData)); + callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData), + callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData)); } } diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 5fc9a79..a2215cf 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -16,7 +16,7 @@ public sealed class AuthorizedSessionServiceTests ], sessions: [ - new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); var service = new AuthorizedSessionService(store); @@ -56,7 +56,7 @@ public sealed class AuthorizedSessionServiceTests ], sessions: [ - new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); var service = new AuthorizedSessionService(store); @@ -78,7 +78,7 @@ public sealed class AuthorizedSessionServiceTests ], sessions: [ - new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); var service = new AuthorizedSessionService(store); @@ -99,11 +99,11 @@ public sealed class AuthorizedSessionServiceTests ], sessions: [ - new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); var service = new AuthorizedSessionService(store); - var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b"); + var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); await Assert.ThrowsAsync(action); Assert.False(store.UpdateCalled); @@ -123,11 +123,11 @@ public sealed class AuthorizedSessionServiceTests ], sessions: [ - new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); var service = new AuthorizedSessionService(store); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b"); + await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5); Assert.True(store.UpdateCalled); Assert.Equal(groupId, store.LastUpdatedGroupId); @@ -135,6 +135,31 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal("Updated", store.LastUpdatedTitle); Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt); Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink); + Assert.Equal(5, store.LastUpdatedMaxPlayers); + } + + [Fact] + public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1) + ]); + var service = new AuthorizedSessionService(store); + + await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId); + + Assert.True(store.PromoteCalled); + Assert.Equal(groupId, store.LastPromotedGroupId); + Assert.Equal(sessionId, store.LastPromotedSessionId); } private sealed class FakeSessionStore( @@ -145,11 +170,15 @@ public sealed class AuthorizedSessionServiceTests private readonly Dictionary sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; public bool UpdateCalled { get; private set; } + public bool PromoteCalled { get; private set; } public Guid? LastUpdatedSessionId { get; private set; } public Guid? LastUpdatedGroupId { get; private set; } public string? LastUpdatedTitle { get; private set; } public DateTime? LastUpdatedScheduledAt { get; private set; } public string? LastUpdatedJoinLink { get; private set; } + public int? LastUpdatedMaxPlayers { get; private set; } + public Guid? LastPromotedSessionId { get; private set; } + public Guid? LastPromotedGroupId { get; private set; } public Task> GetGroupsForGmAsync(long gmId) => Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList()); @@ -169,7 +198,7 @@ public sealed class AuthorizedSessionServiceTests return Task.FromResult(session); } - public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink) + public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { UpdateCalled = true; LastUpdatedSessionId = sessionId; @@ -177,6 +206,15 @@ public sealed class AuthorizedSessionServiceTests LastUpdatedTitle = title; LastUpdatedScheduledAt = scheduledAt; LastUpdatedJoinLink = joinLink; + LastUpdatedMaxPlayers = maxPlayers; + return Task.CompletedTask; + } + + public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) + { + PromoteCalled = true; + LastPromotedSessionId = sessionId; + LastPromotedGroupId = groupId; return Task.CompletedTask; } }