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 @@
-
v1.1.0
+ v1.2.0
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;
}
}