From 2529df415711944fa320b4a2d27a034900cb0f52 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 27 Apr 2026 14:27:16 +0300 Subject: [PATCH] feat: support co-gm group delegation --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 12 +- compose.yaml | 4 +- .../CreateSession/CancelSessionHandler.cs | 28 ++- .../CreateSession/CreateSessionHandler.cs | 62 +++++- .../PromoteWaitlistedPlayerHandler.cs | 17 +- .../ListSessions/DeleteSessionHandler.cs | 43 ++-- .../ListSessions/ListSessionsHandler.cs | 17 +- .../HandleRescheduleTimeInputHandler.cs | 7 + .../InitiateRescheduleHandler.cs | 20 +- .../Migrations/V008__add_group_managers.sql | 26 +++ src/GmRelay.Shared/Domain/GroupManagerRole.cs | 34 +++ .../Components/Pages/GroupDetails.razor | 151 +++++++++++++ src/GmRelay.Web/Components/Pages/Home.razor | 7 + .../Services/AuthorizedSessionService.cs | 58 ++++- src/GmRelay.Web/Services/ISessionStore.cs | 5 + src/GmRelay.Web/Services/SessionService.cs | 173 ++++++++++++++- src/GmRelay.Web/wwwroot/app.css | 2 +- .../Web/AuthorizedSessionServiceTests.cs | 198 +++++++++++++++++- 20 files changed, 805 insertions(+), 63 deletions(-) create mode 100644 src/GmRelay.Bot/Migrations/V008__add_group_managers.sql create mode 100644 src/GmRelay.Shared/Domain/GroupManagerRole.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ac36678..dc7f3fc 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.5.0 + VERSION: 1.6.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 07c997d..be2050f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.5.0 + 1.6.0 net10.0 preview enable diff --git a/README.md b/README.md index 0f3644e..dd931a1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.5.0`. +**Текущая версия:** `v1.6.0`. --- @@ -15,7 +15,7 @@ - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. -- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. +- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram. - **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**. @@ -23,6 +23,7 @@ ### 🌐 Web Dashboard (Blazor Server) - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. +- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard. - **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. - **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. @@ -103,7 +104,7 @@ docker compose up -d * `Закрепление сообщений` — рекомендуется. > [!TIP] -> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе. +> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс. --- @@ -125,8 +126,11 @@ docker compose up -d Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки. +### Делегирование управления +На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM. + ### Bulk-операции в Web Dashboard -На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может: +На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут: - обновить общий `title` и `link` сразу у всех сессий batch; - выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления; - перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях; diff --git a/compose.yaml b/compose.yaml index 7ca3304..acef2d3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.5.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.6.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.5.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.6.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 77bbed8..3926010 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -16,7 +16,7 @@ public sealed record CancelSessionCommand( int MessageId); // DTOs for AOT compilation -internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId, string NotificationMode); +internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode); public sealed class CancelSessionHandler( NpgsqlDataSource dataSource, @@ -29,13 +29,23 @@ public sealed class CancelSessionHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); - // 1. Проверяем, что запрос делает ГМ данной сессии + // 1. Проверяем, что запрос делает управляющий данной группы. var session = await connection.QuerySingleOrDefaultAsync( - @"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode - FROM sessions s - JOIN game_groups g ON s.group_id = g.id - WHERE s.id = @SessionId", - new { command.SessionId }, transaction); + """ + SELECT s.title AS Title, + s.batch_id AS BatchId, + s.notification_mode AS NotificationMode, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = s.group_id + AND p.telegram_id = @TelegramUserId + ) AS CanManage + FROM sessions s + WHERE s.id = @SessionId + """, + new { command.SessionId, command.TelegramUserId }, transaction); if (session == null) { @@ -43,9 +53,9 @@ public sealed class CancelSessionHandler( return; } - if (session.GmId != command.TelegramUserId) + if (!session.CanManage) { - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct); return; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 6eaee43..8916a68 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -7,6 +7,8 @@ using Telegram.Bot.Types; namespace GmRelay.Bot.Features.Sessions.CreateSession; +internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage); + public sealed class CreateSessionHandler( NpgsqlDataSource dataSource, ITelegramBotClient botClient, @@ -74,16 +76,64 @@ public sealed class CreateSessionHandler( new { TgId = gmId, Name = gmName, Username = gmUsername }, transaction); - var groupId = await connection.ExecuteScalarAsync( + var existingGroup = await connection.QuerySingleOrDefaultAsync( """ - INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) - VALUES (@ChatId, @ChatName, @GmId) - ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name - RETURNING id; + SELECT g.id AS GroupId, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = g.id + AND p.telegram_id = @GmId + ) AS CanManage + FROM game_groups g + WHERE g.telegram_chat_id = @ChatId """, - new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, + new { ChatId = chatId, GmId = gmId }, transaction); + Guid groupId; + if (existingGroup is null) + { + groupId = await connection.ExecuteScalarAsync( + """ + INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) + VALUES (@ChatId, @ChatName, @GmId) + RETURNING id; + """, + new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, + transaction); + + await connection.ExecuteAsync( + """ + INSERT INTO group_managers (group_id, player_id, role) + SELECT @GroupId, p.id, @OwnerRole + FROM players p + WHERE p.telegram_id = @GmId + ON CONFLICT (group_id, player_id) DO NOTHING + """, + new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }, + transaction); + } + else + { + if (!existingGroup.CanManage) + { + await transaction.RollbackAsync(cancellationToken); + await botClient.SendMessage( + chatId, + "⛔ Только owner или co-GM этой группы может создавать игровые сессии.", + cancellationToken: cancellationToken); + return; + } + + groupId = existingGroup.GroupId; + await connection.ExecuteAsync( + "UPDATE game_groups SET name = @ChatName WHERE id = @GroupId", + new { ChatName = chatTitle, GroupId = groupId }, + transaction); + } + int? messageThreadId = null; if (message.Chat.IsForum) { diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index 2439cc8..00632f1 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand( long ChatId, int MessageId); -internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers); +internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers); internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName); public sealed class PromoteWaitlistedPlayerHandler( @@ -34,13 +34,18 @@ public sealed class PromoteWaitlistedPlayerHandler( SELECT s.title AS Title, s.batch_id AS BatchId, s.max_players AS MaxPlayers, - g.gm_telegram_id AS GmId + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = s.group_id + AND p.telegram_id = @TelegramUserId + ) AS CanManage FROM sessions s - JOIN game_groups g ON s.group_id = g.id WHERE s.id = @SessionId FOR UPDATE """, - new { command.SessionId }, + new { command.SessionId, command.TelegramUserId }, transaction); if (session is null) @@ -50,10 +55,10 @@ public sealed class PromoteWaitlistedPlayerHandler( return; } - if (session.GmId != command.TelegramUserId) + if (!session.CanManage) { await transaction.RollbackAsync(ct); - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct); return; } diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs index be219fc..0e150fd 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand( long ChatId, int MessageId); -internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId); +internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId); public sealed class DeleteSessionHandler( NpgsqlDataSource dataSource, @@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); - // 1. Fetch session and verify GM + // 1. Fetch session and verify group manager. var session = await connection.QuerySingleOrDefaultAsync( - @"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId - FROM sessions s - JOIN game_groups g ON s.group_id = g.id - WHERE s.id = @SessionId", - new { command.SessionId }, transaction); + """ + SELECT s.title AS Title, + s.batch_id AS BatchId, + s.thread_id AS ThreadId, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = s.group_id + AND p.telegram_id = @TelegramUserId + ) AS CanManage + FROM sessions s + WHERE s.id = @SessionId + """, + new { command.SessionId, command.TelegramUserId }, transaction); if (session == null) { @@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler( return; } - if (session.GmId != command.TelegramUserId) + if (!session.CanManage) { - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct); return; } @@ -77,16 +87,23 @@ public sealed class DeleteSessionHandler( @"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 + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players manager_player ON manager_player.id = gm.player_id + WHERE gm.group_id = s.group_id + AND manager_player.telegram_id = @TelegramUserId + ) AS CanManage 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, s.max_players, g.gm_telegram_id + GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id ORDER BY s.scheduled_at ASC", new { ChatId = command.ChatId, + command.TelegramUserId, Cancelled = SessionStatus.Cancelled, Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted @@ -110,8 +127,8 @@ public sealed class DeleteSessionHandler( text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; } - var isGm = command.TelegramUserId == sessionsList.First().GmId; - var keyboard = isGm + var canManage = sessionsList.First().CanManage; + var keyboard = canManage ? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup( sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") })) : null; diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs index fd2c00d..94a4544 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? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId); +internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage); public sealed class ListSessionsHandler( NpgsqlDataSource dataSource, @@ -20,16 +20,23 @@ public sealed class ListSessionsHandler( @"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 + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players manager_player ON manager_player.id = gm.player_id + WHERE gm.group_id = s.group_id + AND manager_player.telegram_id = @TelegramUserId + ) AS CanManage 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, s.max_players, g.gm_telegram_id + GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id ORDER BY s.scheduled_at ASC", new { ChatId = message.Chat.Id, + TelegramUserId = message.From?.Id, Cancelled = SessionStatus.Cancelled, Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted @@ -56,8 +63,8 @@ public sealed class ListSessionsHandler( text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; } - var isGm = message.From?.Id == sessionsList.First().GmId; - var keyboard = isGm + var canManage = sessionsList.First().CanManage; + var keyboard = canManage ? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup( sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") })) : null; diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index 6ea9c55..b5517f0 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -62,6 +62,13 @@ public sealed class HandleRescheduleTimeInputHandler( WHERE rp.proposed_by = @GmId AND rp.status = 'AwaitingTime' AND g.telegram_chat_id = @ChatId + AND EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players manager_player ON manager_player.id = gm.player_id + WHERE gm.group_id = s.group_id + AND manager_player.telegram_id = @GmId + ) ORDER BY rp.created_at DESC LIMIT 1 """, diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs index 4787959..e195bc1 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs @@ -16,7 +16,7 @@ public sealed record InitiateRescheduleCommand( // ── DTOs ───────────────────────────────────────────────────────────── -internal sealed record RescheduleSessionInfoDto(string Title, long GmId); +internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage); // ── Handler ────────────────────────────────────────────────────────── @@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler( { await using var connection = await dataSource.OpenConnectionAsync(ct); - // 1. Verify GM ownership + // 1. Verify group management access. var session = await connection.QuerySingleOrDefaultAsync( """ - SELECT s.title AS Title, g.gm_telegram_id AS GmId + SELECT s.title AS Title, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = s.group_id + AND p.telegram_id = @TelegramUserId + ) AS CanManage FROM sessions s - JOIN game_groups g ON s.group_id = g.id WHERE s.id = @SessionId AND s.status != @Cancelled """, - new { command.SessionId, Cancelled = SessionStatus.Cancelled }); + new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled }); if (session is null) { @@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler( return; } - if (session.GmId != command.TelegramUserId) + if (!session.CanManage) { await bot.AnswerCallbackQuery(command.CallbackQueryId, - "Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct); + "Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct); return; } diff --git a/src/GmRelay.Bot/Migrations/V008__add_group_managers.sql b/src/GmRelay.Bot/Migrations/V008__add_group_managers.sql new file mode 100644 index 0000000..f8326f9 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V008__add_group_managers.sql @@ -0,0 +1,26 @@ +-- Add explicit owner/co-GM management roles for each Telegram group. + +INSERT INTO players (telegram_id, display_name) +SELECT DISTINCT gg.gm_telegram_id, + 'GM ' || gg.gm_telegram_id::text +FROM game_groups gg +ON CONFLICT (telegram_id) DO NOTHING; + +CREATE TABLE group_managers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE, + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')), + added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (group_id, player_id) +); + +INSERT INTO group_managers (group_id, player_id, role) +SELECT gg.id, p.id, 'Owner' +FROM game_groups gg +JOIN players p ON p.telegram_id = gg.gm_telegram_id +ON CONFLICT (group_id, player_id) DO NOTHING; + +CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role); +CREATE INDEX ix_group_managers_player ON group_managers (player_id); diff --git a/src/GmRelay.Shared/Domain/GroupManagerRole.cs b/src/GmRelay.Shared/Domain/GroupManagerRole.cs new file mode 100644 index 0000000..b4e491d --- /dev/null +++ b/src/GmRelay.Shared/Domain/GroupManagerRole.cs @@ -0,0 +1,34 @@ +namespace GmRelay.Shared.Domain; + +public enum GroupManagerRole +{ + Owner, + CoGm +} + +public static class GroupManagerRoleExtensions +{ + public const string OwnerValue = "Owner"; + public const string CoGmValue = "CoGm"; + + public static string ToDatabaseValue(this GroupManagerRole role) => role switch + { + GroupManagerRole.Owner => OwnerValue, + GroupManagerRole.CoGm => CoGmValue, + _ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.") + }; + + public static GroupManagerRole FromDatabaseValue(string value) => value switch + { + OwnerValue => GroupManagerRole.Owner, + CoGmValue => GroupManagerRole.CoGm, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown group manager role.") + }; + + public static string ToDisplayName(this GroupManagerRole role) => role switch + { + GroupManagerRole.Owner => "Owner", + GroupManagerRole.CoGm => "Co-GM", + _ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.") + }; +} diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 5708ed3..2ae51f9 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -20,6 +20,57 @@

📅 Предстоящие игры

+ @if (groupManagement is not null) + { +
+
+
+

Управление группой

+

@groupManagement.Group.Name · @FormatRole(CurrentUserRole)

+
+ @FormatRole(CurrentUserRole) +
+ +
+ @foreach (var manager in groupManagement.Managers) + { + + @FormatManager(manager) + + @if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue) + { + + } + } +
+ + @if (groupManagement.CurrentUserIsOwner) + { + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ } +
+ } + @if (!string.IsNullOrEmpty(errorMessage)) {
@@ -212,12 +263,16 @@ @code { [Parameter] public Guid GroupId { get; set; } private List? sessions; + private WebGroupManagement? groupManagement; private List batchModels = []; private Guid? promotingSessionId; private Guid? processingBatchId; + private long? removingCoGmId; + private bool isAddingCoGm; private long telegramId; private string? errorMessage; private string? successMessage; + private CoGmEditModel coGmModel = new(); protected override async Task OnInitializedAsync() { @@ -233,6 +288,13 @@ private async Task LoadSessions() { + groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId); + if (groupManagement is null) + { + Navigation.NavigateTo("/access-denied"); + return; + } + sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); if (sessions is null) { @@ -243,6 +305,72 @@ RebuildBatchModels(); } + private async Task AddCoGm() + { + errorMessage = null; + successMessage = null; + + if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0) + { + errorMessage = "Telegram ID co-GM должен быть положительным числом."; + return; + } + + isAddingCoGm = true; + + try + { + await SessionService.AddCoGmForOwnerAsync( + GroupId, + telegramId, + coGmModel.TelegramId.Value, + coGmModel.DisplayName, + coGmModel.TelegramUsername); + + coGmModel = new(); + successMessage = "Co-GM добавлен."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось добавить co-GM: " + ex.Message; + } + finally + { + isAddingCoGm = false; + } + } + + private async Task RemoveCoGm(long coGmTelegramId) + { + errorMessage = null; + successMessage = null; + removingCoGmId = coGmTelegramId; + + try + { + await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId); + successMessage = "Co-GM удалён."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось удалить co-GM: " + ex.Message; + } + finally + { + removingCoGmId = null; + } + } + private async Task PromoteWaitlisted(Guid sessionId) { errorMessage = null; @@ -404,6 +532,22 @@ private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; + private string CurrentUserRole => + groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role + ?? GroupManagerRoleExtensions.CoGmValue; + + private static string FormatRole(string role) => + GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName(); + + private static string FormatManager(WebGroupManager manager) + { + var username = string.IsNullOrWhiteSpace(manager.TelegramUsername) + ? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture) + : "@" + manager.TelegramUsername; + + return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}"; + } + private static int InferIntervalDays(IReadOnlyList orderedSessions) { if (orderedSessions.Count < 2) @@ -466,4 +610,11 @@ public int SessionCount { get; init; } public string CloneInterval { get; set; } = "week"; } + + private sealed class CoGmEditModel + { + public long? TelegramId { get; set; } + public string DisplayName { get; set; } = ""; + public string? TelegramUsername { get; set; } + } } diff --git a/src/GmRelay.Web/Components/Pages/Home.razor b/src/GmRelay.Web/Components/Pages/Home.razor index 869307d..70ef8eb 100644 --- a/src/GmRelay.Web/Components/Pages/Home.razor +++ b/src/GmRelay.Web/Components/Pages/Home.razor @@ -1,6 +1,7 @@ @page "/" @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization +@using GmRelay.Shared.Domain @using GmRelay.Web.Services @attribute [Authorize] @inject AuthorizedSessionService SessionService @@ -43,6 +44,9 @@
🎮

@group.Name

ID: @group.TelegramChatId

+ + @FormatRole(group.ManagerRole) + Посмотреть игры → @@ -97,4 +101,7 @@ groups = await SessionService.GetGroupsForGmAsync(telegramId); } + + private static string FormatRole(string role) => + GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName(); } diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 43d3a89..c0512fd 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -7,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) public Task> GetGroupsForGmAsync(long gmId) => sessionStore.GetGroupsForGmAsync(gmId); + public async Task GetGroupManagementForGmAsync(Guid groupId, long gmId) + { + if (!await GroupBelongsToGmAsync(groupId, gmId)) + { + return null; + } + + var group = await sessionStore.GetGroupAsync(groupId); + if (group is null) + { + return null; + } + + var managers = await sessionStore.GetGroupManagersAsync(groupId); + var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId); + return new WebGroupManagement(group, managers, isOwner); + } + public async Task?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) { if (!await GroupBelongsToGmAsync(groupId, gmId)) @@ -110,9 +128,45 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); } + public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) + { + if (coGmTelegramId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero."); + } + + if (ownerTelegramId == coGmTelegramId) + { + throw new InvalidOperationException("Owner is already a group manager."); + } + + if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) + { + throw new SessionAccessDeniedException(groupId, ownerTelegramId); + } + + var normalizedName = string.IsNullOrWhiteSpace(displayName) + ? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + : displayName.Trim(); + var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername) + ? null + : telegramUsername.Trim().TrimStart('@'); + + await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername); + } + + public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId) + { + if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) + { + throw new SessionAccessDeniedException(groupId, ownerTelegramId); + } + + await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId); + } + private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) { - var group = await sessionStore.GetGroupAsync(groupId); - return group?.GmTelegramId == gmId; + return await sessionStore.IsGroupManagerAsync(groupId, gmId); } } diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index df7b95f..5eed200 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -6,6 +6,9 @@ public interface ISessionStore { Task> GetGroupsForGmAsync(long gmId); Task GetGroupAsync(Guid groupId); + Task IsGroupManagerAsync(Guid groupId, long telegramId); + Task IsGroupOwnerAsync(Guid groupId, long telegramId); + Task> GetGroupManagersAsync(Guid groupId); Task> GetUpcomingSessionsAsync(Guid groupId); Task GetSessionAsync(Guid sessionId); Task GetBatchAsync(Guid batchId); @@ -15,4 +18,6 @@ public interface ISessionStore Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode); Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays); Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval); + Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); + Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); } diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 7cc018a..ea6657e 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -6,7 +6,24 @@ using Telegram.Bot; namespace GmRelay.Web.Services; -public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId); +public sealed record WebGameGroup( + Guid Id, + long TelegramChatId, + string Name, + long GmTelegramId, + string ManagerRole = GroupManagerRoleExtensions.OwnerValue); + +public sealed record WebGroupManager( + long TelegramId, + string DisplayName, + string? TelegramUsername, + string Role, + DateTime AddedAt); + +public sealed record WebGroupManagement( + WebGameGroup Group, + IReadOnlyList Managers, + bool CurrentUserIsOwner); public sealed record WebSession( Guid Id, Guid GroupId, @@ -56,7 +73,18 @@ public sealed class SessionService( { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( - "SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId", + """ + SELECT g.id, + g.telegram_chat_id AS TelegramChatId, + g.name, + g.gm_telegram_id AS GmTelegramId, + gm.role AS ManagerRole + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + JOIN game_groups g ON g.id = gm.group_id + WHERE p.telegram_id = @GmId + ORDER BY g.name + """, new { GmId = gmId })).ToList(); } @@ -64,8 +92,145 @@ public sealed class SessionService( { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( - "SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId", - new { GroupId = groupId }); + """ + SELECT g.id, + g.telegram_chat_id AS TelegramChatId, + g.name, + g.gm_telegram_id AS GmTelegramId, + @OwnerRole AS ManagerRole + FROM game_groups g + WHERE g.id = @GroupId + """, + new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); + } + + public async Task IsGroupManagerAsync(Guid groupId, long telegramId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + AND p.telegram_id = @TelegramId + ) + """, + new { GroupId = groupId, TelegramId = telegramId }); + } + + public async Task IsGroupOwnerAsync(Guid groupId, long telegramId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + AND p.telegram_id = @TelegramId + AND gm.role = @OwnerRole + ) + """, + new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); + } + + public async Task> GetGroupManagersAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + SELECT p.telegram_id AS TelegramId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + gm.role AS Role, + gm.created_at AS AddedAt + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + gm.created_at, + p.display_name + """, + new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList(); + } + + public async Task AddGroupCoGmAsync( + Guid groupId, + long ownerTelegramId, + long coGmTelegramId, + string displayName, + string? telegramUsername) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync( + """ + INSERT INTO players (telegram_id, display_name, telegram_username) + VALUES (@TelegramId, @DisplayName, @TelegramUsername) + ON CONFLICT (telegram_id) DO UPDATE + SET display_name = EXCLUDED.display_name, + telegram_username = EXCLUDED.telegram_username + """, + new + { + TelegramId = coGmTelegramId, + DisplayName = displayName, + TelegramUsername = telegramUsername + }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO group_managers (group_id, player_id, role, added_by_player_id) + SELECT @GroupId, + co_gm.id, + @CoGmRole, + owner_player.id + FROM players co_gm + LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId + WHERE co_gm.telegram_id = @CoGmTelegramId + ON CONFLICT (group_id, player_id) DO UPDATE + SET role = CASE + WHEN group_managers.role = @OwnerRole THEN group_managers.role + ELSE EXCLUDED.role + END, + added_by_player_id = EXCLUDED.added_by_player_id + """, + new + { + GroupId = groupId, + OwnerTelegramId = ownerTelegramId, + CoGmTelegramId = coGmTelegramId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue, + CoGmRole = GroupManagerRoleExtensions.CoGmValue + }, + transaction); + + await transaction.CommitAsync(); + } + + public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + """ + DELETE FROM group_managers gm + USING players p + WHERE gm.player_id = p.id + AND gm.group_id = @GroupId + AND p.telegram_id = @CoGmTelegramId + AND gm.role = @CoGmRole + """, + new + { + GroupId = groupId, + CoGmTelegramId = coGmTelegramId, + CoGmRole = GroupManagerRoleExtensions.CoGmValue + }); } public async Task> GetUpcomingSessionsAsync(Guid groupId) diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index cc6e3fb..5d9fd7d 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.5.0 + GM-Relay Design System v1.6.0 Dark RPG Dashboard Theme ============================================ */ diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 806fdff..4a161ae 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -28,6 +28,34 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal("Session A", sessions[0].Title); } + [Fact] + public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenUserIsCoGm() + { + var ownerId = 1001L; + var coGmId = 2002L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", ownerId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) + ], + managers: + [ + new(groupId, coGmId, GroupManagerRole.CoGm) + ]); + var service = new AuthorizedSessionService(store); + + var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId); + + Assert.NotNull(sessions); + Assert.Single(sessions); + Assert.Equal("Session A", sessions[0].Title); + } + [Fact] public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm() { @@ -211,6 +239,105 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink); } + [Fact] + public async Task UpdateBatchDetailsForGmAsync_UpdatesBatch_WhenUserIsCoGm() + { + var ownerId = 1001L; + var coGmId = 2002L; + var groupId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", ownerId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ], + managers: + [ + new(groupId, coGmId, GroupManagerRole.CoGm) + ]); + var service = new AuthorizedSessionService(store); + + await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b"); + + Assert.True(store.UpdateBatchDetailsCalled); + Assert.Equal(batchId, store.LastUpdatedBatchId); + Assert.Equal(groupId, store.LastUpdatedBatchGroupId); + } + + [Fact] + public async Task AddCoGmForOwnerAsync_AddsCoGm_WhenUserIsOwner() + { + var ownerId = 1001L; + var coGmId = 2002L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", ownerId) + ]); + var service = new AuthorizedSessionService(store); + + await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant"); + + Assert.True(store.AddCoGmCalled); + Assert.Equal(groupId, store.LastAddedCoGmGroupId); + Assert.Equal(coGmId, store.LastAddedCoGmTelegramId); + Assert.Equal("Assistant GM", store.LastAddedCoGmDisplayName); + Assert.Equal("assistant", store.LastAddedCoGmUsername); + } + + [Fact] + public async Task AddCoGmForOwnerAsync_Throws_WhenUserIsCoGm() + { + var ownerId = 1001L; + var coGmId = 2002L; + var newCoGmId = 3003L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", ownerId) + ], + managers: + [ + new(groupId, coGmId, GroupManagerRole.CoGm) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null); + + await Assert.ThrowsAsync(action); + Assert.False(store.AddCoGmCalled); + } + + [Fact] + public async Task RemoveCoGmForOwnerAsync_RemovesCoGm_WhenUserIsOwner() + { + var ownerId = 1001L; + var coGmId = 2002L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", ownerId) + ], + managers: + [ + new(groupId, coGmId, GroupManagerRole.CoGm) + ]); + var service = new AuthorizedSessionService(store); + + await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId); + + Assert.True(store.RemoveCoGmCalled); + Assert.Equal(groupId, store.LastRemovedCoGmGroupId); + Assert.Equal(coGmId, store.LastRemovedCoGmTelegramId); + } + [Fact] public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm() { @@ -335,10 +462,12 @@ public sealed class AuthorizedSessionServiceTests private sealed class FakeSessionStore( IEnumerable? groups = null, - IEnumerable? sessions = null) : ISessionStore + IEnumerable? sessions = null, + IEnumerable? managers = null) : ISessionStore { private readonly Dictionary groupsById = groups?.ToDictionary(group => group.Id) ?? []; private readonly Dictionary sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; + private readonly List managers = managers?.ToList() ?? []; public bool UpdateCalled { get; private set; } public bool PromoteCalled { get; private set; } @@ -346,6 +475,8 @@ public sealed class AuthorizedSessionServiceTests public bool UpdateBatchNotificationModeCalled { get; private set; } public bool RescheduleBatchCalled { get; private set; } public bool CloneBatchCalled { get; private set; } + public bool AddCoGmCalled { get; private set; } + public bool RemoveCoGmCalled { get; private set; } public Guid? LastUpdatedSessionId { get; private set; } public Guid? LastUpdatedGroupId { get; private set; } public string? LastUpdatedTitle { get; private set; } @@ -368,9 +499,15 @@ public sealed class AuthorizedSessionServiceTests public Guid? LastClonedBatchId { get; private set; } public Guid? LastClonedBatchGroupId { get; private set; } public BatchCloneInterval? LastCloneInterval { get; private set; } + public Guid? LastAddedCoGmGroupId { get; private set; } + public long? LastAddedCoGmTelegramId { get; private set; } + public string? LastAddedCoGmDisplayName { get; private set; } + public string? LastAddedCoGmUsername { get; private set; } + public Guid? LastRemovedCoGmGroupId { get; private set; } + public long? LastRemovedCoGmTelegramId { get; private set; } public Task> GetGroupsForGmAsync(long gmId) => - Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList()); + Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList()); public Task GetGroupAsync(Guid groupId) { @@ -378,6 +515,36 @@ public sealed class AuthorizedSessionServiceTests return Task.FromResult(group); } + public Task IsGroupManagerAsync(Guid groupId, long telegramId) => + Task.FromResult(IsManager(groupId, telegramId)); + + public Task IsGroupOwnerAsync(Guid groupId, long telegramId) => + Task.FromResult(IsOwner(groupId, telegramId)); + + public Task> GetGroupManagersAsync(Guid groupId) + { + if (!groupsById.TryGetValue(groupId, out var group)) + { + return Task.FromResult(new List()); + } + + var result = new List + { + new(group.GmTelegramId, "Owner GM", null, GroupManagerRoleExtensions.OwnerValue, DateTime.UtcNow) + }; + + result.AddRange(managers + .Where(manager => manager.GroupId == groupId) + .Select(manager => new WebGroupManager( + manager.TelegramId, + $"Co-GM {manager.TelegramId}", + null, + manager.Role.ToDatabaseValue(), + DateTime.UtcNow))); + + return Task.FromResult(result); + } + public Task> GetUpcomingSessionsAsync(Guid groupId) => Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList()); @@ -474,5 +641,32 @@ public sealed class AuthorizedSessionServiceTests DateTime.UtcNow.AddDays(7), 1)); } + + public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) + { + AddCoGmCalled = true; + LastAddedCoGmGroupId = groupId; + LastAddedCoGmTelegramId = coGmTelegramId; + LastAddedCoGmDisplayName = displayName; + LastAddedCoGmUsername = telegramUsername; + return Task.CompletedTask; + } + + public Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId) + { + RemoveCoGmCalled = true; + LastRemovedCoGmGroupId = groupId; + LastRemovedCoGmTelegramId = coGmTelegramId; + return Task.CompletedTask; + } + + private bool IsManager(Guid groupId, long telegramId) => + IsOwner(groupId, telegramId) || + managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); + + private bool IsOwner(Guid groupId, long telegramId) => + groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId; } + + private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role); }