feat: add web batch bulk operations
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.3.0
|
||||
VERSION: 1.4.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.3.0</Version>
|
||||
<Version>1.4.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -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` — Перенести сессию на другое время с голосованием игроков.
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||
✅ @successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessions == null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
@@ -48,6 +55,65 @@
|
||||
}
|
||||
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 *@
|
||||
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
||||
<table class="gm-table">
|
||||
@@ -139,9 +205,12 @@
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
private List<WebSession>? sessions;
|
||||
private List<BatchBulkEditModel> 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<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) =>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
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)
|
||||
{
|
||||
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<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)
|
||||
{
|
||||
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<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
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 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);
|
||||
|
||||
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<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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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<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
|
||||
============================================ */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
IEnumerable<WebGameGroup>? groups = null,
|
||||
IEnumerable<WebSession>? 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<List<WebGameGroup>> 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<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)
|
||||
{
|
||||
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<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