7 Commits

Author SHA1 Message Date
Toutsu 3228e77c7f fix: improve select dropdown contrast
Deploy Telegram Bot / build-and-push (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-27 09:46:40 +03:00
Toutsu 621ef553e7 feat: add web batch bulk operations
Deploy Telegram Bot / build-and-push (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 09:31:51 +03:00
Toutsu 5f3516e703 fix: push registry images with docker cli
Deploy Telegram Bot / build-and-push (push) Successful in 1m7s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-04-24 18:20:15 +03:00
Toutsu 2eb7d86e48 fix: disable build provenance for gitea registry
Deploy Telegram Bot / build-and-push (push) Failing after 30s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:17:20 +03:00
Toutsu 3e291b0ed5 chore: restore registry PAT login
Deploy Telegram Bot / build-and-push (push) Failing after 35s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:11:11 +03:00
Toutsu a5ba4111cf fix: use gitea job token for registry push
Deploy Telegram Bot / build-and-push (push) Failing after 18s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:09:12 +03:00
Toutsu f45985041b feat: allow players to leave sessions
Deploy Telegram Bot / build-and-push (push) Failing after 39s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 17:57:13 +03:00
21 changed files with 1272 additions and 32 deletions
+27 -23
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.2.0
VERSION: 1.4.1
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -23,29 +23,33 @@ jobs:
username: toutsu
password: ${{ secrets.GIT_TOKEN }}
- name: Build and push Bot
uses: docker/build-push-action@v5
with:
context: .
file: src/GmRelay.Bot/Dockerfile
push: true
tags: |
git.codeanddice.ru/toutsu/gmrelay-bot:latest
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
labels: |
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
- name: Build Bot image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.Bot/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-bot:latest \
-t git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} \
.
- name: Build and push Web
uses: docker/build-push-action@v5
with:
context: .
file: src/GmRelay.Web/Dockerfile
push: true
tags: |
git.codeanddice.ru/toutsu/gmrelay-web:latest
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
labels: |
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
- name: Push Bot image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Build Web image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.Web/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-web:latest \
-t git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} \
.
- name: Push Web image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
deploy:
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.2.0</Version>
<Version>1.4.1</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+15 -2
View File
@@ -4,14 +4,16 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.4.1`.
---
## ✨ Ключевые возможности
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию и автоматически ведёт очередь ожидания.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
@@ -20,6 +22,7 @@
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
@@ -118,6 +121,16 @@ docker compose up -d
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Bulk-операции в Web Dashboard
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может:
- обновить общий `title` и `link` сразу у всех сессий batch;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.2.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.4.1
restart: always
depends_on:
db:
@@ -29,7 +29,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.2.0
image: git.codeanddice.ru/toutsu/gmrelay-web:1.4.1
restart: always
depends_on:
db:
@@ -0,0 +1,217 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand(
Guid SessionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string DisplayName);
public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<LeaveSessionHandler> logger)
{
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
var session = await connection.QuerySingleOrDefaultAsync<LeaveSessionInfoDto>(
"""
SELECT title AS Title,
batch_id AS BatchId,
status AS Status,
max_players AS MaxPlayers
FROM sessions
WHERE id = @SessionId
FOR UPDATE
""",
new { command.SessionId },
transaction);
if (session is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (SessionStatus.IsCancelled(session.Status))
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct);
return;
}
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
FOR UPDATE OF sp
""",
new { command.SessionId, command.TelegramUserId },
transaction);
if (participant is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct);
return;
}
await connection.ExecuteAsync(
"""
DELETE FROM session_participants
WHERE id = @ParticipantRowId
""",
new { participant.ParticipantRowId },
transaction);
var activeParticipantsAfterLeave = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Waitlisted
""",
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
string? promotedDisplayName = null;
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
participant.RegistrationStatus,
session.MaxPlayers,
activeParticipantsAfterLeave,
waitlistedParticipants))
{
var promoted = await connection.QuerySingleAsync<LeaveSessionPromotionDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
promotedDisplayName = promoted.DisplayName;
}
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"""
SELECT id AS SessionId,
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
""",
new { session.BatchId },
transaction)).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { session.BatchId },
transaction)).ToList();
await transaction.CommitAsync(ct);
transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания."
: promotedDisplayName is null
? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId);
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
}
}
}
@@ -20,6 +20,7 @@ public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler,
@@ -73,6 +74,19 @@ public sealed class UpdateRouter(
return;
}
if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId))
{
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await leaveSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
{
var command = new CancelSessionCommand(
@@ -210,6 +224,7 @@ public sealed class UpdateRouter(
Ссылка: https://link
/listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка
""",
cancellationToken: ct);
+1
View File
@@ -54,6 +54,7 @@ builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
@@ -23,4 +23,14 @@ public static class SessionCapacityRules
return !maxPlayers.HasValue || activeParticipants < maxPlayers.Value;
}
public static bool ShouldPromoteAfterParticipantLeaves(
string removedRegistrationStatus,
int? maxPlayers,
int activeParticipantsAfterLeave,
int waitlistedParticipants)
{
return removedRegistrationStatus == ParticipantRegistrationStatus.Active
&& CanPromoteWaitlistedPlayer(maxPlayers, activeParticipantsAfterLeave, waitlistedParticipants);
}
}
@@ -60,6 +60,11 @@ public static class SessionBatchRenderer
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
});
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
}
@@ -47,7 +47,7 @@
</button>
</form>
<div class="nav-version">v1.2.0</div>
<div class="nav-version">v1.3.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -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);
}
+264
View File
@@ -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);
}
}
+76 -1
View File
@@ -1,5 +1,5 @@
/* ============================================
GM-Relay Design System v1.2.0
GM-Relay Design System v1.4.1
Dark RPG Dashboard Theme
============================================ */
@@ -363,6 +363,11 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.7);
}
select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* === Tables === */
.gm-table {
width: 100%;
@@ -553,6 +558,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 +837,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;
}
@@ -28,4 +28,26 @@ public sealed class SessionCapacityRulesTests
Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 3, waitlistedParticipants: 1));
Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 0));
}
[Fact]
public void ShouldPromoteAfterParticipantLeaves_ShouldOnlyPromoteAfterActiveParticipantLeaves()
{
Assert.True(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
removedRegistrationStatus: ParticipantRegistrationStatus.Active,
maxPlayers: 2,
activeParticipantsAfterLeave: 1,
waitlistedParticipants: 1));
Assert.False(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
removedRegistrationStatus: ParticipantRegistrationStatus.Waitlisted,
maxPlayers: 2,
activeParticipantsAfterLeave: 1,
waitlistedParticipants: 1));
Assert.False(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
removedRegistrationStatus: ParticipantRegistrationStatus.Active,
maxPlayers: 2,
activeParticipantsAfterLeave: 1,
waitlistedParticipants: 0));
}
}
@@ -42,13 +42,15 @@ public sealed class SessionBatchRendererTests
Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Equal(4, result.Markup.InlineKeyboard.Count());
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData));
@@ -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);
}
}
@@ -0,0 +1,31 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class WebStylesTests
{
[Fact]
public async Task AppCss_ShouldStyleNativeSelectOptionsForReadableDropdowns()
{
var css = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Matches(
@"select\s+option\s*\{[^}]*background:\s*var\(--bg-secondary\);[^}]*color:\s*var\(--text-primary\);",
css);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}