From 0218890a7a54e82d25c848ba7d0d956c0542e4fc Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 28 Apr 2026 10:01:18 +0300 Subject: [PATCH] feat: add campaign templates and recurring schedules --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 22 +- compose.yaml | 4 +- .../CreateSession/CreateSessionHandler.cs | 10 +- .../CreateSession/NewSessionCommandParser.cs | 69 ++++- .../Infrastructure/Telegram/UpdateRouter.cs | 4 + .../V010__add_campaign_templates.sql | 17 ++ .../Components/Pages/GroupDetails.razor | 277 ++++++++++++++++++ .../Services/AuthorizedSessionService.cs | 98 +++++++ .../Services/BatchSchedulePlanner.cs | 45 +++ src/GmRelay.Web/Services/ISessionStore.cs | 5 + src/GmRelay.Web/Services/SessionService.cs | 208 +++++++++++++ src/GmRelay.Web/wwwroot/app.css | 59 +++- .../NewSessionCommandParserTests.cs | 26 ++ .../Web/AuthorizedSessionServiceTests.cs | 212 +++++++++++++- .../Web/BatchSchedulePlannerTests.cs | 29 ++ 17 files changed, 1075 insertions(+), 14 deletions(-) create mode 100644 src/GmRelay.Bot/Migrations/V010__add_campaign_templates.sql diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 397973f..1210d33 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.7.0 + VERSION: 1.8.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 42b0b95..33e7108 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.7.0 + 1.8.0 net10.0 preview enable diff --git a/README.md b/README.md index d875fcd..c09580a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.7.0`. +**Текущая версия:** `v1.8.0`. --- @@ -12,6 +12,7 @@ ### 🤖 Telegram Бот - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). +- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. @@ -25,6 +26,7 @@ - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard. +- **📋 Шаблоны кампаний**: Owner и co-GM сохраняют типовые параметры кампании и создают новый повторяющийся batch из шаблона за один шаг. - **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. - **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. @@ -125,6 +127,20 @@ docker compose up -d Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. +Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях: + +```text +/newsession +Название: Kingmaker +Время: 30.04.2026 19:30 +Игр: 6 +Интервал: 7 +Мест: 5 +Ссылка: https://discord.gg/invite-link +``` + +Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:` — `Шаг:`. + Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки. ### Делегирование управления @@ -143,12 +159,14 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н ### Bulk-операции в Web Dashboard На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут: +- сохранить шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений; +- создать новый batch из шаблона, выбрав только первую дату расписания; - обновить общий `title` и `link` сразу у всех сессий batch; - выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления; - перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях; - клонировать batch на следующую неделю или следующий календарный месяц. -После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков. +После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам. diff --git a/compose.yaml b/compose.yaml index 06d6821..b1e63f7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.7.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.7.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.8.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 8916a68..a446e81 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -42,11 +42,19 @@ public sealed class CreateSessionHandler( cancellationToken: cancellationToken); } + foreach (var recurringInput in parseResult.InvalidRecurringInputs) + { + await botClient.SendMessage( + message.Chat.Id, + $"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.", + 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Мест: 4\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\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7", cancellationToken: cancellationToken); return; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs index 21f151b..e503942 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs @@ -9,17 +9,21 @@ internal sealed record NewSessionParseResult( IReadOnlyList ScheduledTimes, IReadOnlyList PastTimeInputs, IReadOnlyList InvalidTimeInputs, - IReadOnlyList InvalidSeatLimitInputs) + IReadOnlyList InvalidSeatLimitInputs, + IReadOnlyList InvalidRecurringInputs) { public bool IsValid => !string.IsNullOrWhiteSpace(Title) && !string.IsNullOrWhiteSpace(Link) && ScheduledTimes.Count > 0 && - InvalidSeatLimitInputs.Count == 0; + InvalidSeatLimitInputs.Count == 0 && + InvalidRecurringInputs.Count == 0; } internal static class NewSessionCommandParser { + private const int MaxRecurringSessionCount = 52; + private const int MaxRecurringIntervalDays = 365; 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:"; @@ -29,16 +33,30 @@ internal static class NewSessionCommandParser "\u041b\u0438\u043c\u0438\u0442:", "\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:" ]; + private static readonly string[] RecurringCountPrefixes = + [ + "\u0418\u0433\u0440:", + "\u0421\u0435\u0441\u0441\u0438\u0439:", + "\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:" + ]; + private static readonly string[] RecurringIntervalPrefixes = + [ + "\u0428\u0430\u0433:", + "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:" + ]; public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc) { string? title = null; string? link = null; int? maxPlayers = null; + int? recurringCount = null; + var recurringIntervalDays = 7; var scheduledTimes = new List(); var pastTimeInputs = new List(); var invalidTimeInputs = new List(); var invalidSeatLimitInputs = new List(); + var invalidRecurringInputs = new List(); foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) { @@ -71,6 +89,42 @@ internal static class NewSessionCommandParser continue; } + var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix => + line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (recurringCountPrefix is not null) + { + var recurringInput = line[recurringCountPrefix.Length..].Trim(); + if (int.TryParse(recurringInput, out var parsedCount) && + parsedCount is >= 1 and <= MaxRecurringSessionCount) + { + recurringCount = parsedCount; + } + else + { + invalidRecurringInputs.Add(recurringInput); + } + + continue; + } + + var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix => + line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (recurringIntervalPrefix is not null) + { + var recurringInput = line[recurringIntervalPrefix.Length..].Trim(); + if (int.TryParse(recurringInput, out var parsedInterval) && + parsedInterval is >= 1 and <= MaxRecurringIntervalDays) + { + recurringIntervalDays = parsedInterval; + } + else + { + invalidRecurringInputs.Add(recurringInput); + } + + continue; + } + if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase)) { continue; @@ -92,6 +146,14 @@ internal static class NewSessionCommandParser scheduledTimes.Add(scheduledAt); } + if (recurringCount.HasValue && scheduledTimes.Count == 1) + { + var firstScheduledTime = scheduledTimes[0]; + scheduledTimes = Enumerable.Range(0, recurringCount.Value) + .Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index)) + .ToList(); + } + return new NewSessionParseResult( title, link, @@ -99,6 +161,7 @@ internal static class NewSessionCommandParser scheduledTimes, pastTimeInputs, invalidTimeInputs, - invalidSeatLimitInputs); + invalidSeatLimitInputs, + invalidRecurringInputs); } } diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index be7132f..53eb8f2 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -218,6 +218,10 @@ public sealed class UpdateRouter( Мест: 4 Ссылка: https://link + Для регулярного расписания можно указать одну дату: + Игр: 4 + Интервал: 7 + /listsessions — список предстоящих сессий Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования. diff --git a/src/GmRelay.Bot/Migrations/V010__add_campaign_templates.sql b/src/GmRelay.Bot/Migrations/V010__add_campaign_templates.sql new file mode 100644 index 0000000..1ee9429 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V010__add_campaign_templates.sql @@ -0,0 +1,17 @@ +CREATE TABLE campaign_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + title VARCHAR(500) NOT NULL, + join_link TEXT NOT NULL, + session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52), + interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365), + max_players INTEGER CHECK (max_players IS NULL OR max_players > 0), + notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect' + CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (group_id, name) +); + +CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC); diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 2ae51f9..836b84a 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -85,6 +85,83 @@ } + @if (campaignTemplates is not null) + { +
+
+
+

Шаблоны кампаний

+

@campaignTemplateModels.Count сохранённых

+
+ Template +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + @if (campaignTemplateModels.Count > 0) + { +
+ @foreach (var template in campaignTemplateModels) + { +
+
+

@template.Name

+

@FormatTemplateSummary(template)

+
+
+ + + +
+
+ } +
+ } +
+ } + @if (sessions == null) {
@@ -263,16 +340,22 @@ @code { [Parameter] public Guid GroupId { get; set; } private List? sessions; + private List? campaignTemplates; private WebGroupManagement? groupManagement; private List batchModels = []; + private List campaignTemplateModels = []; private Guid? promotingSessionId; private Guid? processingBatchId; + private Guid? processingTemplateId; + private Guid? deletingTemplateId; private long? removingCoGmId; private bool isAddingCoGm; + private bool isCreatingTemplate; private long telegramId; private string? errorMessage; private string? successMessage; private CoGmEditModel coGmModel = new(); + private CampaignTemplateEditModel campaignTemplateModel = new(); protected override async Task OnInitializedAsync() { @@ -302,7 +385,15 @@ return; } + campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId); + if (campaignTemplates is null) + { + Navigation.NavigateTo("/access-denied"); + return; + } + RebuildBatchModels(); + RebuildCampaignTemplateModels(); } private async Task AddCoGm() @@ -497,6 +588,110 @@ } } + private async Task CreateCampaignTemplate() + { + errorMessage = null; + successMessage = null; + + if (!ValidateCampaignTemplate(campaignTemplateModel)) + { + errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан."; + return; + } + + isCreatingTemplate = true; + + try + { + await SessionService.CreateCampaignTemplateForGmAsync( + GroupId, + telegramId, + new CreateCampaignTemplateRequest( + campaignTemplateModel.Name, + campaignTemplateModel.Title, + campaignTemplateModel.JoinLink, + campaignTemplateModel.SessionCount, + campaignTemplateModel.IntervalDays, + campaignTemplateModel.MaxPlayers, + SessionNotificationModeExtensions.FromDatabaseValue(campaignTemplateModel.NotificationMode))); + + campaignTemplateModel = new(); + successMessage = "Шаблон кампании сохранён."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось сохранить шаблон: " + ex.Message; + } + finally + { + isCreatingTemplate = false; + } + } + + private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template) + { + errorMessage = null; + successMessage = null; + processingTemplateId = template.Id; + + try + { + var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; + if (utcTime <= DateTime.UtcNow) + { + errorMessage = "Первая дата batch должна быть в будущем."; + return; + } + + var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime); + successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось создать batch из шаблона: " + ex.Message; + } + finally + { + processingTemplateId = null; + } + } + + private async Task DeleteCampaignTemplate(CampaignTemplateUsageModel template) + { + errorMessage = null; + successMessage = null; + deletingTemplateId = template.Id; + + try + { + await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId); + successMessage = "Шаблон кампании удалён."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось удалить шаблон: " + ex.Message; + } + finally + { + deletingTemplateId = null; + } + } + private void RebuildBatchModels() { batchModels = sessions? @@ -523,6 +718,27 @@ .ToList() ?? []; } + private void RebuildCampaignTemplateModels() + { + var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow(); + campaignTemplateModels = campaignTemplates? + .OrderByDescending(template => template.UpdatedAt) + .ThenBy(template => template.Name) + .Select(template => new CampaignTemplateUsageModel + { + Id = template.Id, + Name = template.Name, + Title = template.Title, + JoinLink = template.JoinLink, + SessionCount = template.SessionCount, + IntervalDays = template.IntervalDays, + MaxPlayers = template.MaxPlayers, + NotificationMode = template.NotificationMode, + FirstScheduledAtLocal = defaultStart + }) + .ToList() ?? []; + } + private static bool ValidateBatchDetails(BatchBulkEditModel batch) { batch.Title = batch.Title.Trim(); @@ -530,8 +746,29 @@ return batch.Title.Length > 0 && batch.JoinLink.Length > 0; } + private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template) + { + template.Name = template.Name.Trim(); + template.Title = template.Title.Trim(); + template.JoinLink = template.JoinLink.Trim(); + + if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0) + { + return false; + } + + return template.Name.Length > 0 && + template.Title.Length > 0 && + template.JoinLink.Length > 0 && + template.SessionCount is >= 1 and <= 52 && + template.IntervalDays is >= 1 and <= 365; + } + private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; + private bool IsTemplateBusy(CampaignTemplateUsageModel template) => + processingTemplateId == template.Id || deletingTemplateId == template.Id; + private string CurrentUserRole => groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role ?? GroupManagerRoleExtensions.CoGmValue; @@ -577,6 +814,22 @@ private static string FormatBatchSummary(BatchBulkEditModel batch) => $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; + private static string FormatTemplateSummary(CampaignTemplateUsageModel template) + { + var seats = template.MaxPlayers.HasValue + ? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест" + : "без лимита"; + + return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}"; + } + + private static string FormatNotificationMode(string notificationMode) => + SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch + { + SessionNotificationMode.GroupOnly => "только группа", + _ => "группа и личка" + }; + private static string FormatLocalMoscow(DateTime localMoscow) => localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU")); @@ -611,6 +864,30 @@ public string CloneInterval { get; set; } = "week"; } + private sealed class CampaignTemplateEditModel + { + public string Name { get; set; } = ""; + public string Title { get; set; } = ""; + public string JoinLink { get; set; } = ""; + public int SessionCount { get; set; } = 6; + public int IntervalDays { get; set; } = 7; + public int? MaxPlayers { get; set; } + public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue; + } + + private sealed class CampaignTemplateUsageModel + { + public Guid Id { get; init; } + public string Name { get; init; } = ""; + public string Title { get; init; } = ""; + public string JoinLink { get; init; } = ""; + public int SessionCount { get; init; } + public int IntervalDays { get; init; } + public int? MaxPlayers { get; init; } + public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue; + public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now; + } + private sealed class CoGmEditModel { public long? TelegramId { get; set; } diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index c0512fd..906c05a 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -128,6 +128,60 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); } + public async Task?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId) + { + if (!await GroupBelongsToGmAsync(groupId, gmId)) + { + return null; + } + + return await sessionStore.GetCampaignTemplatesAsync(groupId); + } + + public async Task CreateCampaignTemplateForGmAsync( + Guid groupId, + long gmId, + CreateCampaignTemplateRequest request) + { + if (!await GroupBelongsToGmAsync(groupId, gmId)) + { + throw new SessionAccessDeniedException(groupId, gmId); + } + + var normalizedRequest = NormalizeCampaignTemplateRequest(request); + return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest); + } + + public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId) + { + var template = await sessionStore.GetCampaignTemplateAsync(templateId); + if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) + { + throw new SessionAccessDeniedException(templateId, gmId); + } + + await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId); + } + + public async Task CreateBatchFromCampaignTemplateForGmAsync( + Guid templateId, + long gmId, + DateTime firstScheduledAt) + { + if (firstScheduledAt <= DateTime.UtcNow) + { + throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future."); + } + + var template = await sessionStore.GetCampaignTemplateAsync(templateId); + if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) + { + throw new SessionAccessDeniedException(templateId, gmId); + } + + return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt); + } + public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) { if (coGmTelegramId <= 0) @@ -169,4 +223,48 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) { return await sessionStore.IsGroupManagerAsync(groupId, gmId); } + + private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request) + { + var name = request.Name.Trim(); + var title = request.Title.Trim(); + var joinLink = request.JoinLink.Trim(); + + if (name.Length == 0) + { + throw new ArgumentException("Template name must not be empty.", nameof(request)); + } + + if (title.Length == 0) + { + throw new ArgumentException("Session title must not be empty.", nameof(request)); + } + + if (joinLink.Length == 0) + { + throw new ArgumentException("Join link must not be empty.", nameof(request)); + } + + if (request.SessionCount is < 1 or > 52) + { + throw new ArgumentOutOfRangeException(nameof(request), request.SessionCount, "Session count must be between 1 and 52."); + } + + if (request.IntervalDays is < 1 or > 365) + { + throw new ArgumentOutOfRangeException(nameof(request), request.IntervalDays, "Interval must be between 1 and 365 days."); + } + + if (request.MaxPlayers is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.MaxPlayers, "Seat limit must be greater than zero."); + } + + return request with + { + Name = name, + Title = title, + JoinLink = joinLink + }; + } } diff --git a/src/GmRelay.Web/Services/BatchSchedulePlanner.cs b/src/GmRelay.Web/Services/BatchSchedulePlanner.cs index 04652ef..9d88a78 100644 --- a/src/GmRelay.Web/Services/BatchSchedulePlanner.cs +++ b/src/GmRelay.Web/Services/BatchSchedulePlanner.cs @@ -18,8 +18,33 @@ public sealed record WebSessionBatch( int SessionCount, string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); +public sealed record WebCampaignTemplate( + Guid Id, + Guid GroupId, + string Name, + string Title, + string JoinLink, + int SessionCount, + int IntervalDays, + int? MaxPlayers, + string NotificationMode, + DateTime CreatedAt, + DateTime UpdatedAt); + +public sealed record CreateCampaignTemplateRequest( + string Name, + string Title, + string JoinLink, + int SessionCount, + int IntervalDays, + int? MaxPlayers, + SessionNotificationMode NotificationMode); + public static class BatchSchedulePlanner { + private const int MaxTemplateSessionCount = 52; + private const int MaxTemplateIntervalDays = 365; + public static IReadOnlyList BuildFixedIntervalSchedule( IEnumerable currentSchedule, DateTime firstScheduledAt, @@ -36,6 +61,26 @@ public static class BatchSchedulePlanner .ToList(); } + public static IReadOnlyList BuildRecurringSchedule( + DateTime firstScheduledAt, + int sessionCount, + int intervalDays) + { + if (sessionCount is < 1 or > MaxTemplateSessionCount) + { + throw new ArgumentOutOfRangeException(nameof(sessionCount), sessionCount, "Session count must be between 1 and 52."); + } + + if (intervalDays is < 1 or > MaxTemplateIntervalDays) + { + throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be between 1 and 365 days."); + } + + return Enumerable.Range(0, sessionCount) + .Select(index => firstScheduledAt.AddDays(intervalDays * index)) + .ToList(); + } + public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) => interval switch { diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index 5eed200..99f8e04 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -18,6 +18,11 @@ 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> GetCampaignTemplatesAsync(Guid groupId); + Task GetCampaignTemplateAsync(Guid templateId); + Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request); + Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId); + Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt); 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 ea6657e..bcea77f 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -63,6 +63,7 @@ internal sealed record WebBatchSessionRow( long TelegramChatId, int? ThreadId, string NotificationMode); +internal sealed record WebTemplateGroupDto(long TelegramChatId); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -753,6 +754,213 @@ public sealed class SessionService( sourceSessions[0].NotificationMode); } + public async Task> GetCampaignTemplatesAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + SELECT id AS Id, + group_id AS GroupId, + name AS Name, + title AS Title, + join_link AS JoinLink, + session_count AS SessionCount, + interval_days AS IntervalDays, + max_players AS MaxPlayers, + notification_mode AS NotificationMode, + created_at AS CreatedAt, + updated_at AS UpdatedAt + FROM campaign_templates + WHERE group_id = @GroupId + ORDER BY created_at DESC, name + """, + new { GroupId = groupId })).ToList(); + } + + public async Task GetCampaignTemplateAsync(Guid templateId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + """ + SELECT id AS Id, + group_id AS GroupId, + name AS Name, + title AS Title, + join_link AS JoinLink, + session_count AS SessionCount, + interval_days AS IntervalDays, + max_players AS MaxPlayers, + notification_mode AS NotificationMode, + created_at AS CreatedAt, + updated_at AS UpdatedAt + FROM campaign_templates + WHERE id = @TemplateId + """, + new { TemplateId = templateId }); + } + + public async Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleAsync( + """ + INSERT INTO campaign_templates ( + group_id, + name, + title, + join_link, + session_count, + interval_days, + max_players, + notification_mode + ) + VALUES ( + @GroupId, + @Name, + @Title, + @JoinLink, + @SessionCount, + @IntervalDays, + @MaxPlayers, + @NotificationMode + ) + ON CONFLICT (group_id, name) DO UPDATE + SET title = EXCLUDED.title, + join_link = EXCLUDED.join_link, + session_count = EXCLUDED.session_count, + interval_days = EXCLUDED.interval_days, + max_players = EXCLUDED.max_players, + notification_mode = EXCLUDED.notification_mode, + updated_at = now() + RETURNING id AS Id, + group_id AS GroupId, + name AS Name, + title AS Title, + join_link AS JoinLink, + session_count AS SessionCount, + interval_days AS IntervalDays, + max_players AS MaxPlayers, + notification_mode AS NotificationMode, + created_at AS CreatedAt, + updated_at AS UpdatedAt + """, + new + { + GroupId = groupId, + request.Name, + request.Title, + request.JoinLink, + request.SessionCount, + request.IntervalDays, + request.MaxPlayers, + NotificationMode = request.NotificationMode.ToDatabaseValue() + }); + } + + public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + "DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId", + new { TemplateId = templateId, GroupId = groupId }); + } + + public async Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var template = await conn.QuerySingleOrDefaultAsync( + """ + SELECT id AS Id, + group_id AS GroupId, + name AS Name, + title AS Title, + join_link AS JoinLink, + session_count AS SessionCount, + interval_days AS IntervalDays, + max_players AS MaxPlayers, + notification_mode AS NotificationMode, + created_at AS CreatedAt, + updated_at AS UpdatedAt + FROM campaign_templates + WHERE id = @TemplateId + AND group_id = @GroupId + FOR UPDATE + """, + new { TemplateId = templateId, GroupId = groupId }, + transaction); + + if (template is null) + { + throw new SessionAccessDeniedException(templateId, 0); + } + + var group = await conn.QuerySingleOrDefaultAsync( + "SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId", + new { GroupId = groupId }, + transaction); + + if (group is null) + { + throw new SessionAccessDeniedException(groupId, 0); + } + + var schedule = BatchSchedulePlanner.BuildRecurringSchedule( + firstScheduledAt, + template.SessionCount, + template.IntervalDays); + var batchId = Guid.NewGuid(); + var renderedSessions = new List(); + + foreach (var scheduledAt in schedule) + { + var sessionId = await conn.ExecuteScalarAsync( + """ + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode) + VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode) + RETURNING id + """, + new + { + BatchId = batchId, + GroupId = groupId, + template.Title, + template.JoinLink, + ScheduledAt = scheduledAt, + Status = SessionStatus.Planned, + template.MaxPlayers, + template.NotificationMode + }, + transaction); + + renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers)); + } + + await transaction.CommitAsync(); + + var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty()); + var batchMessage = await bot.SendMessage( + chatId: group.TelegramChatId, + text: renderResult.Text, + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + replyMarkup: renderResult.Markup); + + await conn.ExecuteAsync( + "UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId", + new { MessageId = batchMessage.MessageId, BatchId = batchId }); + + return new WebSessionBatch( + batchId, + groupId, + template.Title, + template.JoinLink, + renderedSessions.Min(session => session.ScheduledAt), + renderedSessions.Max(session => session.ScheduledAt), + renderedSessions.Count, + template.NotificationMode); + } + private async Task> LoadSessionDirectRecipientsAsync( Npgsql.NpgsqlConnection conn, Guid sessionId) diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 6b423ca..26e3ea5 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.7.0 + GM-Relay Design System v1.8.0 Dark RPG Dashboard Theme ============================================ */ @@ -618,6 +618,55 @@ select option { white-space: nowrap; } +/* === Campaign templates === */ +.campaign-template-panel { + margin-bottom: 1.5rem; +} + +.campaign-template-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.campaign-template-list { + margin-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.campaign-template-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, auto); + gap: 1rem; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--border-color); +} + +.campaign-template-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.campaign-template-info h3 { + font-size: 0.9375rem; + margin-bottom: 0.25rem; + overflow-wrap: anywhere; +} + +.campaign-template-info p { + margin: 0; + color: var(--text-muted); + font-size: 0.8125rem; +} + +.campaign-template-actions { + display: grid; + grid-template-columns: minmax(190px, 1fr) auto auto; + gap: 0.75rem; + align-items: center; +} + /* === Animations === */ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } @@ -838,11 +887,15 @@ select option { } .batch-bulk-fields, - .batch-clone-row { + .batch-clone-row, + .campaign-template-fields, + .campaign-template-row, + .campaign-template-actions { grid-template-columns: 1fr; } - .batch-clone-row .btn-gm { + .batch-clone-row .btn-gm, + .campaign-template-actions .btn-gm { justify-content: center; width: 100%; } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs index 344b330..7133e1f 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs @@ -33,6 +33,32 @@ public sealed class NewSessionCommandParserTests Assert.Empty(result.InvalidTimeInputs); } + [Fact] + public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided() + { + var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); + var text = """ + /newsession + Название: Kingmaker + Время: 30.04.2026 19:30 + Игр: 4 + Интервал: 14 + Ссылка: https://example.test/kingmaker + """; + + var result = NewSessionCommandParser.Parse(text, nowUtc); + + Assert.True(result.IsValid); + Assert.Equal( + [ + new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero) + ], + result.ScheduledTimes); + } + [Fact] public void Parse_ShouldCollectPastAndInvalidTimes() { diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 4a161ae..6c01f08 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -460,13 +460,153 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval); } + [Fact] + public async Task CreateCampaignTemplateForGmAsync_CreatesTemplate_WhenGroupBelongsToGm() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ]); + var service = new AuthorizedSessionService(store); + + await service.CreateCampaignTemplateForGmAsync( + groupId, + gmId, + new CreateCampaignTemplateRequest( + " Weekly arc ", + " Kingmaker ", + " https://example.test/kingmaker ", + SessionCount: 6, + IntervalDays: 7, + MaxPlayers: 5, + SessionNotificationMode.GroupOnly)); + + Assert.True(store.CreateCampaignTemplateCalled); + Assert.Equal(groupId, store.LastCreatedCampaignTemplateGroupId); + Assert.Equal("Weekly arc", store.LastCreatedCampaignTemplateRequest?.Name); + Assert.Equal("Kingmaker", store.LastCreatedCampaignTemplateRequest?.Title); + Assert.Equal("https://example.test/kingmaker", store.LastCreatedCampaignTemplateRequest?.JoinLink); + Assert.Equal(6, store.LastCreatedCampaignTemplateRequest?.SessionCount); + Assert.Equal(7, store.LastCreatedCampaignTemplateRequest?.IntervalDays); + Assert.Equal(5, store.LastCreatedCampaignTemplateRequest?.MaxPlayers); + Assert.Equal(SessionNotificationMode.GroupOnly, store.LastCreatedCampaignTemplateRequest?.NotificationMode); + } + + [Fact] + public async Task CreateCampaignTemplateForGmAsync_AllowsNoSeatLimit() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ]); + var service = new AuthorizedSessionService(store); + + await service.CreateCampaignTemplateForGmAsync( + groupId, + gmId, + new CreateCampaignTemplateRequest( + "Open table", + "West Marches", + "https://example.test/west", + 8, + 7, + null, + SessionNotificationMode.GroupAndDirect)); + + Assert.True(store.CreateCampaignTemplateCalled); + Assert.Null(store.LastCreatedCampaignTemplateRequest?.MaxPlayers); + } + + [Fact] + public async Task CreateCampaignTemplateForGmAsync_Throws_WhenGroupBelongsToAnotherGm() + { + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.CreateCampaignTemplateForGmAsync( + groupId, + 1001L, + new CreateCampaignTemplateRequest( + "Weekly arc", + "Kingmaker", + "https://example.test/kingmaker", + SessionCount: 6, + IntervalDays: 7, + MaxPlayers: 5, + SessionNotificationMode.GroupOnly)); + + await Assert.ThrowsAsync(action); + Assert.False(store.CreateCampaignTemplateCalled); + } + + [Fact] + public async Task CreateBatchFromCampaignTemplateForGmAsync_CreatesBatch_WhenTemplateGroupBelongsToGm() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var templateId = Guid.NewGuid(); + var firstScheduledAt = DateTime.UtcNow.AddDays(3); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + templates: + [ + new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) + ]); + var service = new AuthorizedSessionService(store); + + await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt); + + Assert.True(store.CreateBatchFromTemplateCalled); + Assert.Equal(templateId, store.LastCreatedBatchTemplateId); + Assert.Equal(groupId, store.LastCreatedBatchTemplateGroupId); + Assert.Equal(firstScheduledAt, store.LastCreatedBatchFirstScheduledAt); + } + + [Fact] + public async Task CreateBatchFromCampaignTemplateForGmAsync_Throws_WhenTemplateGroupBelongsToAnotherGm() + { + var groupId = Guid.NewGuid(); + var templateId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ], + templates: + [ + new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3)); + + await Assert.ThrowsAsync(action); + Assert.False(store.CreateBatchFromTemplateCalled); + } + private sealed class FakeSessionStore( IEnumerable? groups = null, IEnumerable? sessions = null, - IEnumerable? managers = null) : ISessionStore + IEnumerable? managers = null, + IEnumerable? templates = null) : ISessionStore { private readonly Dictionary groupsById = groups?.ToDictionary(group => group.Id) ?? []; private readonly Dictionary sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; + private readonly Dictionary templatesById = templates?.ToDictionary(template => template.Id) ?? []; private readonly List managers = managers?.ToList() ?? []; public bool UpdateCalled { get; private set; } @@ -475,6 +615,9 @@ public sealed class AuthorizedSessionServiceTests public bool UpdateBatchNotificationModeCalled { get; private set; } public bool RescheduleBatchCalled { get; private set; } public bool CloneBatchCalled { get; private set; } + public bool CreateCampaignTemplateCalled { get; private set; } + public bool DeleteCampaignTemplateCalled { get; private set; } + public bool CreateBatchFromTemplateCalled { get; private set; } public bool AddCoGmCalled { get; private set; } public bool RemoveCoGmCalled { get; private set; } public Guid? LastUpdatedSessionId { get; private set; } @@ -499,6 +642,13 @@ public sealed class AuthorizedSessionServiceTests public Guid? LastClonedBatchId { get; private set; } public Guid? LastClonedBatchGroupId { get; private set; } public BatchCloneInterval? LastCloneInterval { get; private set; } + public Guid? LastCreatedCampaignTemplateGroupId { get; private set; } + public CreateCampaignTemplateRequest? LastCreatedCampaignTemplateRequest { get; private set; } + public Guid? LastDeletedCampaignTemplateId { get; private set; } + public Guid? LastDeletedCampaignTemplateGroupId { get; private set; } + public Guid? LastCreatedBatchTemplateId { get; private set; } + public Guid? LastCreatedBatchTemplateGroupId { get; private set; } + public DateTime? LastCreatedBatchFirstScheduledAt { get; private set; } public Guid? LastAddedCoGmGroupId { get; private set; } public long? LastAddedCoGmTelegramId { get; private set; } public string? LastAddedCoGmDisplayName { get; private set; } @@ -642,6 +792,66 @@ public sealed class AuthorizedSessionServiceTests 1)); } + public Task> GetCampaignTemplatesAsync(Guid groupId) => + Task.FromResult(templatesById.Values.Where(template => template.GroupId == groupId).ToList()); + + public Task GetCampaignTemplateAsync(Guid templateId) + { + templatesById.TryGetValue(templateId, out var template); + return Task.FromResult(template); + } + + public Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) + { + CreateCampaignTemplateCalled = true; + LastCreatedCampaignTemplateGroupId = groupId; + LastCreatedCampaignTemplateRequest = request; + + var template = new WebCampaignTemplate( + Guid.NewGuid(), + groupId, + request.Name, + request.Title, + request.JoinLink, + request.SessionCount, + request.IntervalDays, + request.MaxPlayers, + request.NotificationMode.ToDatabaseValue(), + DateTime.UtcNow, + DateTime.UtcNow); + + templatesById[template.Id] = template; + return Task.FromResult(template); + } + + public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) + { + DeleteCampaignTemplateCalled = true; + LastDeletedCampaignTemplateId = templateId; + LastDeletedCampaignTemplateGroupId = groupId; + templatesById.Remove(templateId); + return Task.CompletedTask; + } + + public Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) + { + CreateBatchFromTemplateCalled = true; + LastCreatedBatchTemplateId = templateId; + LastCreatedBatchTemplateGroupId = groupId; + LastCreatedBatchFirstScheduledAt = firstScheduledAt; + + var template = templatesById[templateId]; + return Task.FromResult(new WebSessionBatch( + Guid.NewGuid(), + groupId, + template.Title, + template.JoinLink, + firstScheduledAt, + firstScheduledAt.AddDays(template.IntervalDays * (template.SessionCount - 1)), + template.SessionCount, + template.NotificationMode)); + } + public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) { AddCoGmCalled = true; diff --git a/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs b/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs index 5a5fd73..c08da50 100644 --- a/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs @@ -37,6 +37,35 @@ public sealed class BatchSchedulePlannerTests Assert.Throws(action); } + [Fact] + public void BuildRecurringSchedule_CreatesFixedIntervalScheduleFromFirstDate() + { + var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc); + + var result = BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount: 3, intervalDays: 14); + + Assert.Equal( + [ + firstScheduledAt, + firstScheduledAt.AddDays(14), + firstScheduledAt.AddDays(28) + ], + result); + } + + [Theory] + [InlineData(0, 7)] + [InlineData(53, 7)] + [InlineData(3, 0)] + public void BuildRecurringSchedule_RejectsInvalidTemplateShape(int sessionCount, int intervalDays) + { + var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc); + + var action = () => BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount, intervalDays); + + Assert.Throws(action); + } + [Theory] [InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)] [InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]