From 621ef553e7614538feac54068319573e7c618996 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 27 Apr 2026 09:31:51 +0300 Subject: [PATCH] feat: add web batch bulk operations --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 11 +- compose.yaml | 4 +- .../Components/Pages/GroupDetails.razor | 240 +++++++++++++++- .../Services/AuthorizedSessionService.cs | 49 ++++ .../Services/BatchSchedulePlanner.cs | 43 +++ src/GmRelay.Web/Services/ISessionStore.cs | 4 + src/GmRelay.Web/Services/SessionService.cs | 264 ++++++++++++++++++ src/GmRelay.Web/wwwroot/app.css | 72 ++++- .../Web/AuthorizedSessionServiceTests.cs | 196 +++++++++++++ .../Web/BatchSchedulePlannerTests.cs | 51 ++++ 12 files changed, 931 insertions(+), 7 deletions(-) create mode 100644 src/GmRelay.Web/Services/BatchSchedulePlanner.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e73b06f..d368d2b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.3.0 + VERSION: 1.4.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 24961a9..8769be4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.3.0 + 1.4.0 net10.0 preview enable diff --git a/README.md b/README.md index 8937e5f..b6071ff 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.3.0`. +**Текущая версия:** `v1.4.0`. --- @@ -22,6 +22,7 @@ ### 🌐 Web Dashboard (Blazor Server) - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. +- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. - **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC. @@ -122,6 +123,14 @@ docker compose up -d Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки. +### Bulk-операции в Web Dashboard +На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может: +- обновить общий `title` и `link` сразу у всех сессий batch; +- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях; +- клонировать batch на следующую неделю или следующий календарный месяц. + +После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков. + ### Другие команды - `/listsessions` — Показать список всех актуальных игр в этой группе. - `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. diff --git a/compose.yaml b/compose.yaml index f9bf5f6..e6eb6a5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.3.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.4.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.3.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.4.0 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 8cfd145..15d06ef 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -27,6 +27,13 @@ } + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ ✅ @successMessage +
+ } + @if (sessions == null) {
@@ -48,6 +55,65 @@ } else { +
+ @foreach (var batch in batchModels) + { +
+
+
+

@batch.Title

+

@FormatBatchSummary(batch)

+
+ Batch +
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ } +
+ @* Desktop table *@
@@ -139,9 +205,12 @@ @code { [Parameter] public Guid GroupId { get; set; } private List? sessions; + private List batchModels = []; private Guid? promotingSessionId; + private Guid? processingBatchId; private long telegramId; private string? errorMessage; + private string? successMessage; protected override async Task OnInitializedAsync() { @@ -152,22 +221,31 @@ return; } + await LoadSessions(); + } + + private async Task LoadSessions() + { sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); if (sessions is null) { Navigation.NavigateTo("/access-denied"); + return; } + + RebuildBatchModels(); } private async Task PromoteWaitlisted(Guid sessionId) { errorMessage = null; + successMessage = null; promotingSessionId = sessionId; try { await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId); - sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); + await LoadSessions(); } catch (SessionAccessDeniedException) { @@ -183,6 +261,148 @@ } } + private async Task UpdateBatchDetails(BatchBulkEditModel batch) + { + errorMessage = null; + successMessage = null; + + if (!ValidateBatchDetails(batch)) + { + errorMessage = "Название и ссылка для batch не должны быть пустыми."; + return; + } + + processingBatchId = batch.BatchId; + + try + { + await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink); + successMessage = "Общие title/link обновлены для всей пачки."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось обновить пачку: " + ex.Message; + } + finally + { + processingBatchId = null; + } + } + + private async Task RescheduleBatch(BatchBulkEditModel batch) + { + errorMessage = null; + successMessage = null; + + if (batch.IntervalDays <= 0) + { + errorMessage = "Шаг между играми должен быть больше 0 дней."; + return; + } + + processingBatchId = batch.BatchId; + + try + { + var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; + await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays); + successMessage = "Расписание пачки обновлено."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось перенести пачку: " + ex.Message; + } + finally + { + processingBatchId = null; + } + } + + private async Task CloneBatch(BatchBulkEditModel batch) + { + errorMessage = null; + successMessage = null; + processingBatchId = batch.BatchId; + + try + { + var interval = batch.CloneInterval == "month" + ? BatchCloneInterval.NextMonth + : BatchCloneInterval.NextWeek; + + var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval); + successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр."; + await LoadSessions(); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось клонировать пачку: " + ex.Message; + } + finally + { + processingBatchId = null; + } + } + + private void RebuildBatchModels() + { + batchModels = sessions? + .GroupBy(session => session.BatchId) + .Select(group => + { + var orderedSessions = group.OrderBy(session => session.ScheduledAt).ToList(); + var firstSession = orderedSessions[0]; + var lastSession = orderedSessions[^1]; + + return new BatchBulkEditModel + { + BatchId = group.Key, + Title = firstSession.Title, + JoinLink = firstSession.JoinLink, + FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(), + LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(), + IntervalDays = InferIntervalDays(orderedSessions), + SessionCount = orderedSessions.Count + }; + }) + .OrderBy(batch => batch.FirstScheduledAtLocal) + .ToList() ?? []; + } + + private static bool ValidateBatchDetails(BatchBulkEditModel batch) + { + batch.Title = batch.Title.Trim(); + batch.JoinLink = batch.JoinLink.Trim(); + return batch.Title.Length > 0 && batch.JoinLink.Length > 0; + } + + private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; + + private static int InferIntervalDays(IReadOnlyList orderedSessions) + { + if (orderedSessions.Count < 2) + { + return 7; + } + + var days = (orderedSessions[1].ScheduledAt - orderedSessions[0].ScheduledAt).TotalDays; + return Math.Max(1, (int)Math.Round(days, MidpointRounding.AwayFromZero)); + } + private static bool CanPromote(WebSession session) => session.WaitlistedPlayerCount > 0 && (!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value); @@ -198,6 +418,12 @@ : seats; } + private static string FormatBatchSummary(BatchBulkEditModel batch) => + $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; + + private static string FormatLocalMoscow(DateTime localMoscow) => + localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU")); + private string GetStatusClass(string status) => status switch { SessionStatus.Confirmed => "status-success", @@ -215,4 +441,16 @@ SessionStatus.Cancelled => "Отменено", _ => status }; + + private sealed class BatchBulkEditModel + { + public Guid BatchId { get; init; } + public string Title { get; set; } = ""; + public string JoinLink { get; set; } = ""; + public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now; + public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now; + public int IntervalDays { get; set; } = 7; + public int SessionCount { get; init; } + public string CloneInterval { get; set; } = "week"; + } } diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 28c4b7b..e2d1ba6 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -26,6 +26,17 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; } + public async Task GetBatchForGmAsync(Guid batchId, long gmId) + { + var batch = await sessionStore.GetBatchAsync(batchId); + if (batch is null) + { + return null; + } + + return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null; + } + public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { var session = await GetSessionForGmAsync(sessionId, gmId); @@ -48,6 +59,44 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); } + public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink) + { + var batch = await GetBatchForGmAsync(batchId, gmId); + if (batch is null) + { + throw new SessionAccessDeniedException(batchId, gmId); + } + + await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim()); + } + + public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays) + { + if (intervalDays <= 0) + { + throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero."); + } + + var batch = await GetBatchForGmAsync(batchId, gmId); + if (batch is null) + { + throw new SessionAccessDeniedException(batchId, gmId); + } + + await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays); + } + + public async Task CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval) + { + var batch = await GetBatchForGmAsync(batchId, gmId); + if (batch is null) + { + throw new SessionAccessDeniedException(batchId, gmId); + } + + return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); + } + private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) { var group = await sessionStore.GetGroupAsync(groupId); diff --git a/src/GmRelay.Web/Services/BatchSchedulePlanner.cs b/src/GmRelay.Web/Services/BatchSchedulePlanner.cs new file mode 100644 index 0000000..a35e87b --- /dev/null +++ b/src/GmRelay.Web/Services/BatchSchedulePlanner.cs @@ -0,0 +1,43 @@ +namespace GmRelay.Web.Services; + +public enum BatchCloneInterval +{ + NextWeek, + NextMonth +} + +public sealed record WebSessionBatch( + Guid Id, + Guid GroupId, + string Title, + string JoinLink, + DateTime FirstScheduledAt, + DateTime LastScheduledAt, + int SessionCount); + +public static class BatchSchedulePlanner +{ + public static IReadOnlyList BuildFixedIntervalSchedule( + IEnumerable currentSchedule, + DateTime firstScheduledAt, + int intervalDays) + { + if (intervalDays <= 0) + { + throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero."); + } + + return currentSchedule + .OrderBy(scheduledAt => scheduledAt) + .Select((_, index) => firstScheduledAt.AddDays(intervalDays * index)) + .ToList(); + } + + public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) => + interval switch + { + BatchCloneInterval.NextWeek => scheduledAt.AddDays(7), + BatchCloneInterval.NextMonth => scheduledAt.AddMonths(1), + _ => throw new ArgumentOutOfRangeException(nameof(interval), interval, "Unknown clone interval.") + }; +} diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index f9bb888..ebc4a50 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -6,6 +6,10 @@ public interface ISessionStore Task GetGroupAsync(Guid groupId); Task> GetUpcomingSessionsAsync(Guid groupId); Task GetSessionAsync(Guid sessionId); + Task GetBatchAsync(Guid batchId); Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers); Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId); + Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink); + Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays); + Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval); } diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 9250a94..2975cd4 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -22,6 +22,26 @@ public sealed record WebSession( int WaitlistedPlayerCount); internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); +internal sealed record WebBatchInfo( + Guid BatchId, + Guid GroupId, + string Title, + string JoinLink, + long TelegramChatId, + int? BatchMessageId, + int? ThreadId); + +internal sealed record WebBatchSessionRow( + Guid Id, + Guid GroupId, + string Title, + string JoinLink, + DateTime ScheduledAt, + string Status, + int? MaxPlayers, + int? BatchMessageId, + long TelegramChatId, + int? ThreadId); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -115,6 +135,25 @@ public sealed class SessionService( }); } + public async Task GetBatchAsync(Guid batchId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + """ + SELECT s.batch_id AS Id, + s.group_id AS GroupId, + (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, + (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, + MIN(s.scheduled_at) AS FirstScheduledAt, + MAX(s.scheduled_at) AS LastScheduledAt, + COUNT(*)::int AS SessionCount + FROM sessions s + WHERE s.batch_id = @BatchId + GROUP BY s.batch_id, s.group_id + """, + new { BatchId = batchId }); + } + public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { await using var conn = await dataSource.OpenConnectionAsync(); @@ -282,6 +321,206 @@ public sealed class SessionService( } } + public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); + if (batch is null) + { + throw new SessionAccessDeniedException(batchId, 0); + } + + var updatedRows = await conn.ExecuteAsync( + """ + UPDATE sessions + SET title = @Title, + join_link = @JoinLink, + updated_at = now() + WHERE batch_id = @BatchId + AND group_id = @GroupId + """, + new + { + BatchId = batchId, + GroupId = groupId, + Title = title, + JoinLink = joinLink + }, + transaction); + + if (updatedRows == 0) + { + throw new SessionAccessDeniedException(batchId, 0); + } + + await transaction.CommitAsync(); + + if (batch.BatchMessageId.HasValue) + { + await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title); + } + } + + public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var batchSessions = (await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.group_id AS GroupId, + s.title AS Title, + s.join_link AS JoinLink, + s.scheduled_at AS ScheduledAt, + s.status AS Status, + s.max_players AS MaxPlayers, + s.batch_message_id AS BatchMessageId, + g.telegram_chat_id AS TelegramChatId, + s.thread_id AS ThreadId + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.batch_id = @BatchId + AND s.group_id = @GroupId + ORDER BY s.scheduled_at + FOR UPDATE + """, + new { BatchId = batchId, GroupId = groupId }, + transaction)).ToList(); + + if (batchSessions.Count == 0) + { + throw new SessionAccessDeniedException(batchId, 0); + } + + var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( + batchSessions.Select(session => session.ScheduledAt), + firstScheduledAt, + intervalDays); + + for (var index = 0; index < batchSessions.Count; index++) + { + await conn.ExecuteAsync( + """ + UPDATE sessions + SET scheduled_at = @ScheduledAt, + updated_at = now() + WHERE id = @SessionId + """, + new + { + SessionId = batchSessions[index].Id, + ScheduledAt = newSchedule[index] + }, + transaction); + } + + await transaction.CommitAsync(); + + var firstSession = batchSessions[0]; + if (firstSession.BatchMessageId.HasValue) + { + await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title); + } + + var notification = $"🔄 Мастер обновил расписание пачки\n\n" + + $"📌 {System.Net.WebUtility.HtmlEncode(firstSession.Title)}\n" + + $"🗓 Новое начало: {firstScheduledAt.FormatMoscow()} (МСК)\n" + + $"↔️ Шаг: {intervalDays} дн."; + + await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + } + + public async Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var sourceSessions = (await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.group_id AS GroupId, + s.title AS Title, + s.join_link AS JoinLink, + s.scheduled_at AS ScheduledAt, + s.status AS Status, + s.max_players AS MaxPlayers, + s.batch_message_id AS BatchMessageId, + g.telegram_chat_id AS TelegramChatId, + s.thread_id AS ThreadId + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.batch_id = @BatchId + AND s.group_id = @GroupId + ORDER BY s.scheduled_at + FOR UPDATE + """, + new { BatchId = batchId, GroupId = groupId }, + transaction)).ToList(); + + if (sourceSessions.Count == 0) + { + throw new SessionAccessDeniedException(batchId, 0); + } + + var newBatchId = Guid.NewGuid(); + var batchTitle = sourceSessions[0].Title; + var batchJoinLink = sourceSessions[0].JoinLink; + var chatId = sourceSessions[0].TelegramChatId; + var threadId = sourceSessions[0].ThreadId; + var renderedSessions = new List(); + + foreach (var sourceSession in sourceSessions) + { + var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval); + var sessionId = await conn.ExecuteScalarAsync( + """ + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players) + VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers) + RETURNING id + """, + new + { + BatchId = newBatchId, + sourceSession.GroupId, + Title = batchTitle, + JoinLink = batchJoinLink, + ScheduledAt = scheduledAt, + Status = SessionStatus.Planned, + ThreadId = threadId, + sourceSession.MaxPlayers + }, + transaction); + + renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers)); + } + + await transaction.CommitAsync(); + + var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty()); + var batchMessage = await bot.SendMessage( + chatId: chatId, + messageThreadId: threadId, + 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 = newBatchId }); + + return new WebSessionBatch( + newBatchId, + groupId, + batchTitle, + batchJoinLink, + renderedSessions.Min(session => session.ScheduledAt), + renderedSessions.Max(session => session.ScheduledAt), + renderedSessions.Count); + } + private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title) { try @@ -318,4 +557,29 @@ public sealed class SessionService( logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId); } } + + private static async Task GetBatchInfoAsync( + Npgsql.NpgsqlConnection conn, + Guid batchId, + Guid groupId, + Npgsql.NpgsqlTransaction transaction) + { + return await conn.QuerySingleOrDefaultAsync( + """ + SELECT s.batch_id AS BatchId, + s.group_id AS GroupId, + (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, + (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, + g.telegram_chat_id AS TelegramChatId, + (array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId, + (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.batch_id = @BatchId + AND s.group_id = @GroupId + GROUP BY s.batch_id, s.group_id, g.telegram_chat_id + """, + new { BatchId = batchId, GroupId = groupId }, + transaction); + } } diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 1a1244d..cde001b 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.3.0 + GM-Relay Design System v1.4.0 Dark RPG Dashboard Theme ============================================ */ @@ -553,6 +553,66 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator { gap: 1rem; } +/* === Batch bulk operations === */ +.batch-bulk-grid { + display: grid; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.batch-bulk-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.25rem; +} + +.batch-bulk-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +.batch-bulk-header h3 { + font-size: 1rem; + margin-bottom: 0.25rem; + overflow-wrap: anywhere; +} + +.batch-bulk-header p { + margin: 0; + color: var(--text-muted); + font-size: 0.8125rem; +} + +.batch-bulk-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.batch-bulk-divider { + height: 1px; + background: var(--border-color); + margin: 1rem 0; +} + +.batch-clone-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + margin-top: 1rem; +} + +.batch-clone-row .btn-gm { + white-space: nowrap; +} + /* === Animations === */ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } @@ -772,6 +832,16 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator { grid-template-columns: 1fr; } + .batch-bulk-fields, + .batch-clone-row { + grid-template-columns: 1fr; + } + + .batch-clone-row .btn-gm { + justify-content: center; + width: 100%; + } + .page-container { padding: 1rem; } diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index a2215cf..0769290 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -162,6 +162,129 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal(sessionId, store.LastPromotedSessionId); } + [Fact] + public async Task UpdateBatchDetailsForGmAsync_Throws_WhenBatchBelongsToAnotherGm() + { + var batchId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b"); + + await Assert.ThrowsAsync(action); + Assert.False(store.UpdateBatchDetailsCalled); + } + + [Fact] + public async Task UpdateBatchDetailsForGmAsync_UpdatesOwnedBatch() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b"); + + Assert.True(store.UpdateBatchDetailsCalled); + Assert.Equal(batchId, store.LastUpdatedBatchId); + Assert.Equal(groupId, store.LastUpdatedBatchGroupId); + Assert.Equal("Updated", store.LastUpdatedBatchTitle); + Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink); + } + + [Fact] + public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0); + + await Assert.ThrowsAsync(action); + Assert.False(store.RescheduleBatchCalled); + } + + [Fact] + public async Task RescheduleBatchForGmAsync_ReschedulesOwnedBatch() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var firstScheduledAt = DateTime.UtcNow.AddDays(7); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14); + + Assert.True(store.RescheduleBatchCalled); + Assert.Equal(batchId, store.LastRescheduledBatchId); + Assert.Equal(groupId, store.LastRescheduledBatchGroupId); + Assert.Equal(firstScheduledAt, store.LastRescheduledFirstScheduledAt); + Assert.Equal(14, store.LastRescheduledIntervalDays); + } + + [Fact] + public async Task CloneBatchForGmAsync_ClonesOwnedBatch() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek); + + Assert.True(store.CloneBatchCalled); + Assert.Equal(batchId, store.LastClonedBatchId); + Assert.Equal(groupId, store.LastClonedBatchGroupId); + Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval); + } + private sealed class FakeSessionStore( IEnumerable? groups = null, IEnumerable? sessions = null) : ISessionStore @@ -171,6 +294,9 @@ public sealed class AuthorizedSessionServiceTests public bool UpdateCalled { get; private set; } public bool PromoteCalled { get; private set; } + public bool UpdateBatchDetailsCalled { get; private set; } + public bool RescheduleBatchCalled { get; private set; } + public bool CloneBatchCalled { get; private set; } public Guid? LastUpdatedSessionId { get; private set; } public Guid? LastUpdatedGroupId { get; private set; } public string? LastUpdatedTitle { get; private set; } @@ -179,6 +305,17 @@ public sealed class AuthorizedSessionServiceTests public int? LastUpdatedMaxPlayers { get; private set; } public Guid? LastPromotedSessionId { get; private set; } public Guid? LastPromotedGroupId { get; private set; } + public Guid? LastUpdatedBatchId { get; private set; } + public Guid? LastUpdatedBatchGroupId { get; private set; } + public string? LastUpdatedBatchTitle { get; private set; } + public string? LastUpdatedBatchJoinLink { get; private set; } + public Guid? LastRescheduledBatchId { get; private set; } + public Guid? LastRescheduledBatchGroupId { get; private set; } + public DateTime? LastRescheduledFirstScheduledAt { get; private set; } + public int? LastRescheduledIntervalDays { get; private set; } + public Guid? LastClonedBatchId { get; private set; } + public Guid? LastClonedBatchGroupId { get; private set; } + public BatchCloneInterval? LastCloneInterval { get; private set; } public Task> GetGroupsForGmAsync(long gmId) => Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList()); @@ -198,6 +335,29 @@ public sealed class AuthorizedSessionServiceTests return Task.FromResult(session); } + public Task GetBatchAsync(Guid batchId) + { + var batchSessions = sessionsById.Values + .Where(session => session.BatchId == batchId) + .OrderBy(session => session.ScheduledAt) + .ToList(); + + if (batchSessions.Count == 0) + { + return Task.FromResult(null); + } + + var firstSession = batchSessions[0]; + return Task.FromResult(new( + batchId, + firstSession.GroupId, + firstSession.Title, + firstSession.JoinLink, + firstSession.ScheduledAt, + batchSessions[^1].ScheduledAt, + batchSessions.Count)); + } + public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { UpdateCalled = true; @@ -217,5 +377,41 @@ public sealed class AuthorizedSessionServiceTests LastPromotedGroupId = groupId; return Task.CompletedTask; } + + public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) + { + UpdateBatchDetailsCalled = true; + LastUpdatedBatchId = batchId; + LastUpdatedBatchGroupId = groupId; + LastUpdatedBatchTitle = title; + LastUpdatedBatchJoinLink = joinLink; + return Task.CompletedTask; + } + + public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) + { + RescheduleBatchCalled = true; + LastRescheduledBatchId = batchId; + LastRescheduledBatchGroupId = groupId; + LastRescheduledFirstScheduledAt = firstScheduledAt; + LastRescheduledIntervalDays = intervalDays; + return Task.CompletedTask; + } + + public Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) + { + CloneBatchCalled = true; + LastClonedBatchId = batchId; + LastClonedBatchGroupId = groupId; + LastCloneInterval = interval; + return Task.FromResult(new WebSessionBatch( + Guid.NewGuid(), + groupId, + "Session A", + "https://example.test/a", + DateTime.UtcNow.AddDays(7), + DateTime.UtcNow.AddDays(7), + 1)); + } } } diff --git a/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs b/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs new file mode 100644 index 0000000..5a5fd73 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/BatchSchedulePlannerTests.cs @@ -0,0 +1,51 @@ +using GmRelay.Web.Services; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class BatchSchedulePlannerTests +{ + [Fact] + public void BuildFixedIntervalSchedule_OrdersSessionsAndAppliesInterval() + { + var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc); + var currentSchedule = new[] + { + new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 4, 21, 16, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 5, 16, 0, 0, DateTimeKind.Utc) + }; + + var result = BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 7); + + Assert.Equal( + [ + firstScheduledAt, + firstScheduledAt.AddDays(7), + firstScheduledAt.AddDays(14) + ], + result); + } + + [Fact] + public void BuildFixedIntervalSchedule_RejectsNonPositiveInterval() + { + var currentSchedule = new[] { new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc) }; + var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc); + + var action = () => BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 0); + + Assert.Throws(action); + } + + [Theory] + [InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)] + [InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)] + public void ShiftForClone_AppliesRequestedCalendarShift(BatchCloneInterval interval, int expectedYear, int expectedMonth, int expectedDay) + { + var scheduledAt = new DateTime(2026, 5, 1, 16, 30, 0, DateTimeKind.Utc); + + var result = BatchSchedulePlanner.ShiftForClone(scheduledAt, interval); + + Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, 16, 30, 0, DateTimeKind.Utc), result); + } +}