feat: add web batch bulk operations
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.3.0
|
VERSION: 1.4.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.3.0</Version>
|
<Version>1.4.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.3.0`.
|
**Текущая версия:** `v1.4.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
### 🌐 Web Dashboard (Blazor Server)
|
### 🌐 Web Dashboard (Blazor Server)
|
||||||
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
||||||
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
||||||
|
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
||||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
||||||
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
||||||
@@ -122,6 +123,14 @@ docker compose up -d
|
|||||||
|
|
||||||
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
||||||
|
|
||||||
|
### Bulk-операции в Web Dashboard
|
||||||
|
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может:
|
||||||
|
- обновить общий `title` и `link` сразу у всех сессий batch;
|
||||||
|
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
|
||||||
|
- клонировать batch на следующую неделю или следующий календарный месяц.
|
||||||
|
|
||||||
|
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
|
||||||
|
|
||||||
### Другие команды
|
### Другие команды
|
||||||
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
||||||
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
|
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.3.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.4.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- gmrelay
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.3.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.4.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(successMessage))
|
||||||
|
{
|
||||||
|
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||||
|
✅ @successMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (sessions == null)
|
@if (sessions == null)
|
||||||
{
|
{
|
||||||
<div class="glass-card" style="padding: 2rem;">
|
<div class="glass-card" style="padding: 2rem;">
|
||||||
@@ -48,6 +55,65 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="batch-bulk-grid animate-slide-up">
|
||||||
|
@foreach (var batch in batchModels)
|
||||||
|
{
|
||||||
|
<div class="batch-bulk-card">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>@batch.Title</h3>
|
||||||
|
<p>@FormatBatchSummary(batch)</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-info">Batch</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="@batch" OnValidSubmit="@(() => UpdateBatchDetails(batch))">
|
||||||
|
<div class="batch-bulk-fields">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Общее название</label>
|
||||||
|
<InputText @bind-Value="batch.Title" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Общая ссылка</label>
|
||||||
|
<InputText @bind-Value="batch.JoinLink" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
|
||||||
|
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить title/link")
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<div class="batch-bulk-divider"></div>
|
||||||
|
|
||||||
|
<EditForm Model="@batch" OnValidSubmit="@(() => RescheduleBatch(batch))">
|
||||||
|
<div class="batch-bulk-fields">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Первая дата пачки (МСК, UTC+3)</label>
|
||||||
|
<input type="datetime-local" @bind="batch.FirstScheduledAtLocal" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Шаг между играми, дней</label>
|
||||||
|
<InputNumber @bind-Value="batch.IntervalDays" class="gm-form-control" min="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-gm btn-gm-outline" disabled="@IsBatchBusy(batch)">
|
||||||
|
@(IsBatchBusy(batch) ? "⏳ Переносим..." : "📅 Перенести пачку")
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<div class="batch-clone-row">
|
||||||
|
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||||
|
<option value="week">Следующая неделя</option>
|
||||||
|
<option value="month">Следующий месяц</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn-gm btn-gm-success" disabled="@IsBatchBusy(batch)" @onclick="() => CloneBatch(batch)">
|
||||||
|
@(IsBatchBusy(batch) ? "⏳ Клонируем..." : "🧬 Клонировать")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@* Desktop table *@
|
@* Desktop table *@
|
||||||
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
||||||
<table class="gm-table">
|
<table class="gm-table">
|
||||||
@@ -139,9 +205,12 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid GroupId { get; set; }
|
[Parameter] public Guid GroupId { get; set; }
|
||||||
private List<WebSession>? sessions;
|
private List<WebSession>? sessions;
|
||||||
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
|
private Guid? processingBatchId;
|
||||||
private long telegramId;
|
private long telegramId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -152,22 +221,31 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSessions()
|
||||||
|
{
|
||||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||||
if (sessions is null)
|
if (sessions is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RebuildBatchModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PromoteWaitlisted(Guid sessionId)
|
private async Task PromoteWaitlisted(Guid sessionId)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
promotingSessionId = sessionId;
|
promotingSessionId = sessionId;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
|
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
|
||||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
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<WebSession> 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) =>
|
private static bool CanPromote(WebSession session) =>
|
||||||
session.WaitlistedPlayerCount > 0 &&
|
session.WaitlistedPlayerCount > 0 &&
|
||||||
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
|
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
|
||||||
@@ -198,6 +418,12 @@
|
|||||||
: seats;
|
: 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
|
private string GetStatusClass(string status) => status switch
|
||||||
{
|
{
|
||||||
SessionStatus.Confirmed => "status-success",
|
SessionStatus.Confirmed => "status-success",
|
||||||
@@ -215,4 +441,16 @@
|
|||||||
SessionStatus.Cancelled => "Отменено",
|
SessionStatus.Cancelled => "Отменено",
|
||||||
_ => status
|
_ => 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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WebSessionBatch?> 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)
|
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
@@ -48,6 +59,44 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
|
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<WebSessionBatch> 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<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||||
{
|
{
|
||||||
var group = await sessionStore.GetGroupAsync(groupId);
|
var group = await sessionStore.GetGroupAsync(groupId);
|
||||||
|
|||||||
@@ -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<DateTime> BuildFixedIntervalSchedule(
|
||||||
|
IEnumerable<DateTime> 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.")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ public interface ISessionStore
|
|||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||||
|
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
|
||||||
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
|
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
|
||||||
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
|
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<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ public sealed record WebSession(
|
|||||||
int WaitlistedPlayerCount);
|
int WaitlistedPlayerCount);
|
||||||
|
|
||||||
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
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(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -115,6 +135,25 @@ public sealed class SessionService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<WebSessionBatch>(
|
||||||
|
"""
|
||||||
|
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)
|
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var 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<WebBatchSessionRow>(
|
||||||
|
"""
|
||||||
|
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 = $"🔄 <b>Мастер обновил расписание пачки</b>\n\n" +
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(firstSession.Title)}</b>\n" +
|
||||||
|
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
|
||||||
|
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
|
||||||
|
|
||||||
|
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebSessionBatch> 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<WebBatchSessionRow>(
|
||||||
|
"""
|
||||||
|
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<SessionBatchDto>();
|
||||||
|
|
||||||
|
foreach (var sourceSession in sourceSessions)
|
||||||
|
{
|
||||||
|
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||||||
|
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
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<ParticipantBatchDto>());
|
||||||
|
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)
|
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -318,4 +557,29 @@ public sealed class SessionService(
|
|||||||
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||||||
|
Npgsql.NpgsqlConnection conn,
|
||||||
|
Guid batchId,
|
||||||
|
Guid groupId,
|
||||||
|
Npgsql.NpgsqlTransaction transaction)
|
||||||
|
{
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<WebBatchInfo>(
|
||||||
|
"""
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GM-Relay Design System v1.3.0
|
GM-Relay Design System v1.4.0
|
||||||
Dark RPG Dashboard Theme
|
Dark RPG Dashboard Theme
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@@ -553,6 +553,66 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
gap: 1rem;
|
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 === */
|
/* === Animations === */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
@@ -772,6 +832,16 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.page-container {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,129 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Equal(sessionId, store.LastPromotedSessionId);
|
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<SessionAccessDeniedException>(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<ArgumentOutOfRangeException>(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(
|
private sealed class FakeSessionStore(
|
||||||
IEnumerable<WebGameGroup>? groups = null,
|
IEnumerable<WebGameGroup>? groups = null,
|
||||||
IEnumerable<WebSession>? sessions = null) : ISessionStore
|
IEnumerable<WebSession>? sessions = null) : ISessionStore
|
||||||
@@ -171,6 +294,9 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
|
|
||||||
public bool UpdateCalled { get; private set; }
|
public bool UpdateCalled { get; private set; }
|
||||||
public bool PromoteCalled { 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? LastUpdatedSessionId { get; private set; }
|
||||||
public Guid? LastUpdatedGroupId { get; private set; }
|
public Guid? LastUpdatedGroupId { get; private set; }
|
||||||
public string? LastUpdatedTitle { get; private set; }
|
public string? LastUpdatedTitle { get; private set; }
|
||||||
@@ -179,6 +305,17 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public int? LastUpdatedMaxPlayers { get; private set; }
|
public int? LastUpdatedMaxPlayers { get; private set; }
|
||||||
public Guid? LastPromotedSessionId { get; private set; }
|
public Guid? LastPromotedSessionId { get; private set; }
|
||||||
public Guid? LastPromotedGroupId { 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<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||||
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
|
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
|
||||||
@@ -198,6 +335,29 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.FromResult(session);
|
return Task.FromResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
|
||||||
|
{
|
||||||
|
var batchSessions = sessionsById.Values
|
||||||
|
.Where(session => session.BatchId == batchId)
|
||||||
|
.OrderBy(session => session.ScheduledAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (batchSessions.Count == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult<WebSessionBatch?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSession = batchSessions[0];
|
||||||
|
return Task.FromResult<WebSessionBatch?>(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)
|
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||||
{
|
{
|
||||||
UpdateCalled = true;
|
UpdateCalled = true;
|
||||||
@@ -217,5 +377,41 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
LastPromotedGroupId = groupId;
|
LastPromotedGroupId = groupId;
|
||||||
return Task.CompletedTask;
|
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<WebSessionBatch> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ArgumentOutOfRangeException>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user