Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2529df4157 | |||
| a8f2b10956 | |||
| 3228e77c7f | |||
| 621ef553e7 | |||
| 5f3516e703 | |||
| 2eb7d86e48 | |||
| 3e291b0ed5 | |||
| a5ba4111cf | |||
| f45985041b |
+27
-23
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.2.0
|
||||
VERSION: 1.6.0
|
||||
|
||||
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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.2.0</Version>
|
||||
<Version>1.6.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,22 +4,28 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v1.6.0`.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
|
||||
### 🤖 Telegram Бот
|
||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
|
||||
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
|
||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию и автоматически ведёт очередь ожидания.
|
||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
||||
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
||||
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
|
||||
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
|
||||
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
||||
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
|
||||
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
|
||||
|
||||
### 🌐 Web Dashboard (Blazor Server)
|
||||
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
||||
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
||||
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
|
||||
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
||||
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
||||
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
||||
@@ -98,7 +104,7 @@ docker compose up -d
|
||||
* `Закрепление сообщений` — рекомендуется.
|
||||
|
||||
> [!TIP]
|
||||
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
|
||||
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,6 +124,22 @@ docker compose up -d
|
||||
|
||||
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
|
||||
|
||||
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
||||
|
||||
### Делегирование управления
|
||||
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
|
||||
|
||||
### Bulk-операции в Web Dashboard
|
||||
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
|
||||
- обновить общий `title` и `link` сразу у всех сессий batch;
|
||||
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
||||
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
|
||||
- клонировать batch на следующую неделю или следующий календарный месяц.
|
||||
|
||||
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
|
||||
|
||||
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
|
||||
|
||||
### Другие команды
|
||||
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
||||
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
|
||||
|
||||
+2
-2
@@ -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.6.0
|
||||
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.6.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
@@ -13,7 +14,8 @@ internal sealed record SessionInfo(
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
Guid GroupId,
|
||||
long TelegramChatId);
|
||||
long TelegramChatId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ParticipantInfo(
|
||||
long TelegramId,
|
||||
@@ -29,6 +31,7 @@ internal sealed record ParticipantInfo(
|
||||
public sealed class SendConfirmationHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendConfirmationHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
@@ -39,7 +42,8 @@ public sealed class SendConfirmationHandler(
|
||||
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
|
||||
"""
|
||||
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.status = @Planned
|
||||
@@ -115,6 +119,26 @@ public sealed class SendConfirmationHandler(
|
||||
MessageId = message.MessageId
|
||||
});
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var directText = $"""
|
||||
🎲 <b>Подтвердите участие в игре</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
Ответьте кнопкой в групповом сообщении расписания.
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
|
||||
directText,
|
||||
"confirmation",
|
||||
sessionId,
|
||||
ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||
sessionId, session.Title, message.MessageId);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace GmRelay.Bot.Features.Notifications;
|
||||
|
||||
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||
|
||||
public sealed class DirectSessionNotificationSender(
|
||||
ITelegramBotClient bot,
|
||||
ILogger<DirectSessionNotificationSender> logger)
|
||||
{
|
||||
public async Task SendAsync(
|
||||
IEnumerable<DirectNotificationRecipient> recipients,
|
||||
string htmlText,
|
||||
string notificationKind,
|
||||
Guid sessionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: recipient.TelegramId,
|
||||
text: htmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})",
|
||||
notificationKind,
|
||||
sessionId,
|
||||
recipient.TelegramId,
|
||||
recipient.DisplayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
@@ -12,7 +13,8 @@ internal sealed record JoinLinkSession(
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
long TelegramChatId);
|
||||
long TelegramChatId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ConfirmedPlayer(
|
||||
long TelegramId,
|
||||
@@ -28,6 +30,7 @@ internal sealed record ConfirmedPlayer(
|
||||
public sealed class SendJoinLinkHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendJoinLinkHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
@@ -38,7 +41,8 @@ public sealed class SendJoinLinkHandler(
|
||||
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
|
||||
"""
|
||||
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
@@ -102,6 +106,24 @@ public sealed class SendJoinLinkHandler(
|
||||
""",
|
||||
new { SessionId = sessionId, MessageId = message.MessageId });
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var directText = $"""
|
||||
🎮 <b>Игра начинается через 5 минут</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
|
||||
directText,
|
||||
"join-link",
|
||||
sessionId,
|
||||
ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||
sessionId, session.Title, message.MessageId);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
|
||||
internal sealed record OneHourReminderSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
string NotificationMode);
|
||||
|
||||
public sealed class SendOneHourReminderHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendOneHourReminderHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
join_link AS JoinLink,
|
||||
scheduled_at AS ScheduledAt,
|
||||
notification_mode AS NotificationMode
|
||||
FROM sessions
|
||||
WHERE id = @SessionId
|
||||
AND status IN (@Confirmed, @ConfirmationSent)
|
||||
AND one_hour_reminder_processed_at IS NULL
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Confirmed = SessionStatus.Confirmed,
|
||||
ConfirmationSent = SessionStatus.ConfirmationSent
|
||||
});
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not eligible for one-hour reminder", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
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 = @Active
|
||||
AND sp.rsvp_status != @Declined
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Declined = RsvpStatus.Declined
|
||||
})).ToList();
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
|
||||
{
|
||||
var text = $"""
|
||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET one_hour_reminder_processed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND one_hour_reminder_processed_at IS NULL
|
||||
""",
|
||||
new { SessionId = sessionId });
|
||||
|
||||
logger.LogInformation(
|
||||
"One-hour reminder processed for session {SessionId} ({Title}) with mode {NotificationMode}",
|
||||
sessionId,
|
||||
session.Title,
|
||||
session.NotificationMode);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
@@ -15,11 +16,12 @@ public sealed record CancelSessionCommand(
|
||||
int MessageId);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
|
||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
|
||||
|
||||
public sealed class CancelSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<CancelSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
|
||||
@@ -27,13 +29,23 @@ public sealed class CancelSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Проверяем, что запрос делает ГМ данной сессии
|
||||
// 1. Проверяем, что запрос делает управляющий данной группы.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.notification_mode AS NotificationMode,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
@@ -41,9 +53,9 @@ public sealed class CancelSessionHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,6 +85,19 @@ public sealed class CancelSessionHandler(
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||
new { BatchId = session.BatchId }, transaction);
|
||||
|
||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction)).ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
@@ -92,6 +117,17 @@ public sealed class CancelSessionHandler(
|
||||
|
||||
// Опционально: написать отдельное сообщение в чат
|
||||
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
directRecipients,
|
||||
$"❌ <b>Сессия отменена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>",
|
||||
"session-cancelled",
|
||||
command.SessionId,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@ using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient,
|
||||
@@ -74,16 +76,64 @@ public sealed class CreateSessionHandler(
|
||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||
transaction);
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId)
|
||||
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id;
|
||||
SELECT g.id AS GroupId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = g.id
|
||||
AND p.telegram_id = @GmId
|
||||
) AS CanManage
|
||||
FROM game_groups g
|
||||
WHERE g.telegram_chat_id = @ChatId
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
new { ChatId = chatId, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
Guid groupId;
|
||||
if (existingGroup is null)
|
||||
{
|
||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId)
|
||||
RETURNING id;
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE p.telegram_id = @GmId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existingGroup.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = existingGroup.GroupId;
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
|
||||
new { ChatName = chatTitle, GroupId = groupId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
int? messageThreadId = null;
|
||||
if (message.Chat.IsForum)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers);
|
||||
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers);
|
||||
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||
|
||||
public sealed class PromoteWaitlistedPlayerHandler(
|
||||
@@ -34,13 +34,18 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.max_players AS MaxPlayers,
|
||||
g.gm_telegram_id AS GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE
|
||||
""",
|
||||
new { command.SessionId },
|
||||
new { command.SessionId, command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
@@ -50,10 +55,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
|
||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Fetch session and verify GM
|
||||
// 1. Fetch session and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.thread_id AS ThreadId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
@@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,16 +87,23 @@ public sealed class DeleteSessionHandler(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
g.gm_telegram_id as GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = command.ChatId,
|
||||
command.TelegramUserId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
@@ -110,8 +127,8 @@ public sealed class DeleteSessionHandler(
|
||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
var isGm = command.TelegramUserId == sessionsList.First().GmId;
|
||||
var keyboard = isGm
|
||||
var canManage = sessionsList.First().CanManage;
|
||||
var keyboard = canManage
|
||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||
: null;
|
||||
|
||||
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId);
|
||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
|
||||
public sealed class ListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -20,16 +20,23 @@ public sealed class ListSessionsHandler(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
g.gm_telegram_id as GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = message.Chat.Id,
|
||||
TelegramUserId = message.From?.Id,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
@@ -56,8 +63,8 @@ public sealed class ListSessionsHandler(
|
||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
var isGm = message.From?.Id == sessionsList.First().GmId;
|
||||
var keyboard = isGm
|
||||
var canManage = sessionsList.First().CanManage;
|
||||
var keyboard = canManage
|
||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||
: null;
|
||||
|
||||
+49
-5
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
@@ -12,9 +13,13 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||
Guid BatchId, int? BatchMessageId, long TelegramChatId);
|
||||
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
|
||||
|
||||
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
|
||||
internal sealed record VoteParticipantDto(
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
long TelegramId = 0);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +31,7 @@ internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, str
|
||||
public sealed class HandleRescheduleTimeInputHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -48,13 +54,21 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
"""
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE rp.proposed_by = @GmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.telegram_chat_id = @ChatId
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @GmId
|
||||
)
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
@@ -86,7 +100,10 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
// 3. Load participants (non-GM) signed up for this session
|
||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
||||
SELECT p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.telegram_id AS TelegramId
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
@@ -135,6 +152,29 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var directText = $"""
|
||||
🔄 <b>Голосование за перенос сессии</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК)
|
||||
|
||||
Проголосуйте кнопкой в групповом сообщении.
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
participants.Select(p => new DirectNotificationRecipient(
|
||||
p.TelegramId,
|
||||
p.DisplayName)),
|
||||
directText,
|
||||
"reschedule-vote",
|
||||
proposal.SessionId,
|
||||
ct);
|
||||
}
|
||||
|
||||
// Store vote message ID
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
||||
@@ -156,7 +196,11 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @NewTime,
|
||||
status = @Status,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
@@ -25,11 +26,13 @@ internal sealed record VoteProposalDto(
|
||||
string SessionStatus,
|
||||
long TelegramChatId,
|
||||
int? ConfirmationMessageId,
|
||||
int? BatchMessageId);
|
||||
int? BatchMessageId,
|
||||
string NotificationMode);
|
||||
|
||||
public sealed class HandleRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<HandleRescheduleVoteHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||
@@ -48,7 +51,8 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
s.status AS SessionStatus,
|
||||
s.confirmation_message_id AS ConfirmationMessageId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
@@ -105,7 +109,8 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
"""
|
||||
SELECT p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.telegram_id AS TelegramId
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
@@ -130,6 +135,8 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
|
||||
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
|
||||
{
|
||||
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
||||
new { Id = command.ProposalId },
|
||||
@@ -156,12 +163,25 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
directRecipients,
|
||||
$"❌ <b>Перенос сессии отклонён</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
|
||||
"reschedule-rejected",
|
||||
proposal.SessionId,
|
||||
ct);
|
||||
}
|
||||
|
||||
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.ShouldRescheduleSession)
|
||||
{
|
||||
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
|
||||
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@@ -171,6 +191,7 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
status = @Status,
|
||||
confirmation_message_id = NULL,
|
||||
link_message_id = NULL,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
@@ -214,6 +235,17 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
|
||||
await TryUpdateBatchMessage(proposal, ct);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
directRecipients,
|
||||
$"✅ <b>Сессия перенесена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)",
|
||||
"reschedule-approved",
|
||||
proposal.SessionId,
|
||||
ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
|
||||
proposal.SessionId,
|
||||
@@ -257,6 +289,25 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||||
}
|
||||
|
||||
private static async Task<List<DirectNotificationRecipient>> LoadDirectRecipients(
|
||||
Npgsql.NpgsqlConnection connection,
|
||||
Guid sessionId,
|
||||
Npgsql.NpgsqlTransaction transaction)
|
||||
{
|
||||
return (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
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 = @Active
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction)).ToList();
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record InitiateRescheduleCommand(
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler(
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Verify GM ownership
|
||||
// 1. Verify group management access.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title, g.gm_telegram_id AS GmId
|
||||
SELECT s.title AS Title,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||
""",
|
||||
new { command.SessionId, Cancelled = SessionStatus.Cancelled });
|
||||
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
@@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||
@@ -17,11 +18,13 @@ namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||
public sealed class SessionSchedulerService(
|
||||
NpgsqlDataSource dataSource,
|
||||
SendConfirmationHandler confirmationHandler,
|
||||
SendOneHourReminderHandler oneHourReminderHandler,
|
||||
SendJoinLinkHandler joinLinkHandler,
|
||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -36,6 +39,7 @@ public sealed class SessionSchedulerService(
|
||||
try
|
||||
{
|
||||
await ProcessConfirmationTriggers(stoppingToken);
|
||||
await ProcessOneHourReminderTriggers(stoppingToken);
|
||||
await ProcessJoinLinkTriggers(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
@@ -52,6 +56,42 @@ public sealed class SessionSchedulerService(
|
||||
logger.LogInformation("Session scheduler stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-1h trigger: process direct reminders according to the session notification mode.
|
||||
/// </summary>
|
||||
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var sessionIds = await connection.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sessions
|
||||
WHERE status IN (@Confirmed, @ConfirmationSent)
|
||||
AND scheduled_at - @LeadTime <= now()
|
||||
AND one_hour_reminder_processed_at IS NULL
|
||||
""",
|
||||
new
|
||||
{
|
||||
Confirmed = SessionStatus.Confirmed,
|
||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||
LeadTime = OneHourReminderLeadTime
|
||||
});
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
||||
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-24h trigger: find sessions that need confirmation requests sent.
|
||||
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect'
|
||||
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
|
||||
ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at)
|
||||
WHERE status IN ('Confirmed', 'ConfirmationSent')
|
||||
AND one_hour_reminder_processed_at IS NULL;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Add explicit owner/co-GM management roles for each Telegram group.
|
||||
|
||||
INSERT INTO players (telegram_id, display_name)
|
||||
SELECT DISTINCT gg.gm_telegram_id,
|
||||
'GM ' || gg.gm_telegram_id::text
|
||||
FROM game_groups gg
|
||||
ON CONFLICT (telegram_id) DO NOTHING;
|
||||
|
||||
CREATE TABLE group_managers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
|
||||
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, player_id)
|
||||
);
|
||||
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT gg.id, p.id, 'Owner'
|
||||
FROM game_groups gg
|
||||
JOIN players p ON p.telegram_id = gg.gm_telegram_id
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING;
|
||||
|
||||
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
|
||||
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
|
||||
@@ -1,6 +1,8 @@
|
||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
@@ -50,10 +52,13 @@ builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||
|
||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
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>();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum GroupManagerRole
|
||||
{
|
||||
Owner,
|
||||
CoGm
|
||||
}
|
||||
|
||||
public static class GroupManagerRoleExtensions
|
||||
{
|
||||
public const string OwnerValue = "Owner";
|
||||
public const string CoGmValue = "CoGm";
|
||||
|
||||
public static string ToDatabaseValue(this GroupManagerRole role) => role switch
|
||||
{
|
||||
GroupManagerRole.Owner => OwnerValue,
|
||||
GroupManagerRole.CoGm => CoGmValue,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
|
||||
};
|
||||
|
||||
public static GroupManagerRole FromDatabaseValue(string value) => value switch
|
||||
{
|
||||
OwnerValue => GroupManagerRole.Owner,
|
||||
CoGmValue => GroupManagerRole.CoGm,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown group manager role.")
|
||||
};
|
||||
|
||||
public static string ToDisplayName(this GroupManagerRole role) => role switch
|
||||
{
|
||||
GroupManagerRole.Owner => "Owner",
|
||||
GroupManagerRole.CoGm => "Co-GM",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum SessionNotificationMode
|
||||
{
|
||||
GroupAndDirect,
|
||||
GroupOnly
|
||||
}
|
||||
|
||||
public static class SessionNotificationModeExtensions
|
||||
{
|
||||
public const string GroupAndDirectValue = nameof(SessionNotificationMode.GroupAndDirect);
|
||||
public const string GroupOnlyValue = nameof(SessionNotificationMode.GroupOnly);
|
||||
|
||||
public static bool ShouldSendDirectMessages(this SessionNotificationMode mode) =>
|
||||
mode == SessionNotificationMode.GroupAndDirect;
|
||||
|
||||
public static string ToDatabaseValue(this SessionNotificationMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
SessionNotificationMode.GroupAndDirect => GroupAndDirectValue,
|
||||
SessionNotificationMode.GroupOnly => GroupOnlyValue,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown notification mode.")
|
||||
};
|
||||
|
||||
public static SessionNotificationMode FromDatabaseValue(string? value) =>
|
||||
value switch
|
||||
{
|
||||
null or "" => SessionNotificationMode.GroupAndDirect,
|
||||
GroupAndDirectValue => SessionNotificationMode.GroupAndDirect,
|
||||
GroupOnlyValue => SessionNotificationMode.GroupOnly,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown notification mode.")
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -20,6 +20,57 @@
|
||||
<h2>📅 Предстоящие игры</h2>
|
||||
</div>
|
||||
|
||||
@if (groupManagement is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Управление группой</h3>
|
||||
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
@foreach (var manager in groupManagement.Managers)
|
||||
{
|
||||
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||
@FormatManager(manager)
|
||||
</span>
|
||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
|
||||
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (groupManagement.CurrentUserIsOwner)
|
||||
{
|
||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя</label>
|
||||
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Username</label>
|
||||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||
@(isAddingCoGm ? "⏳ Добавляем..." : "➕ Добавить co-GM")
|
||||
</button>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -27,6 +78,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 +106,72 @@
|
||||
}
|
||||
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 class="gm-form-group">
|
||||
<label class="gm-form-label">Уведомления игрокам</label>
|
||||
<select @bind="batch.NotificationMode" class="gm-form-control">
|
||||
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
|
||||
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
|
||||
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить batch")
|
||||
</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 +263,16 @@
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
private List<WebSession>? sessions;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private long? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private long telegramId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private CoGmEditModel coGmModel = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -152,22 +283,104 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadSessions();
|
||||
}
|
||||
|
||||
private async Task LoadSessions()
|
||||
{
|
||||
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
|
||||
if (groupManagement is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||
if (sessions is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildBatchModels();
|
||||
}
|
||||
|
||||
private async Task AddCoGm()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
|
||||
{
|
||||
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
|
||||
return;
|
||||
}
|
||||
|
||||
isAddingCoGm = true;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.AddCoGmForOwnerAsync(
|
||||
GroupId,
|
||||
telegramId,
|
||||
coGmModel.TelegramId.Value,
|
||||
coGmModel.DisplayName,
|
||||
coGmModel.TelegramUsername);
|
||||
|
||||
coGmModel = new();
|
||||
successMessage = "Co-GM добавлен.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isAddingCoGm = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveCoGm(long coGmTelegramId)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
removingCoGmId = coGmTelegramId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
|
||||
successMessage = "Co-GM удалён.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
removingCoGmId = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 +396,169 @@
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
await SessionService.UpdateBatchNotificationModeForGmAsync(
|
||||
batch.BatchId,
|
||||
telegramId,
|
||||
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
|
||||
successMessage = "Настройки batch обновлены.";
|
||||
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,
|
||||
NotificationMode = firstSession.NotificationMode,
|
||||
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 string CurrentUserRole =>
|
||||
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
|
||||
?? GroupManagerRoleExtensions.CoGmValue;
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
|
||||
private static string FormatManager(WebGroupManager manager)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "@" + manager.TelegramUsername;
|
||||
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
|
||||
}
|
||||
|
||||
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 +574,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 +597,24 @@
|
||||
SessionStatus.Cancelled => "Отменено",
|
||||
_ => status
|
||||
};
|
||||
|
||||
private sealed class BatchBulkEditModel
|
||||
{
|
||||
public Guid BatchId { get; init; }
|
||||
public string Title { get; set; } = "";
|
||||
public string JoinLink { get; set; } = "";
|
||||
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
|
||||
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";
|
||||
}
|
||||
|
||||
private sealed class CoGmEditModel
|
||||
{
|
||||
public long? TelegramId { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string? TelegramUsername { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@@ -43,6 +44,9 @@
|
||||
<div class="group-card-icon">🎮</div>
|
||||
<h3 class="group-card-title">@group.Name</h3>
|
||||
<p class="group-card-id">ID: @group.TelegramChatId</p>
|
||||
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
|
||||
@FormatRole(group.ManagerRole)
|
||||
</span>
|
||||
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||
Посмотреть игры →
|
||||
</a>
|
||||
@@ -97,4 +101,7 @@
|
||||
|
||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||
}
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
@@ -5,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||
sessionStore.GetGroupsForGmAsync(gmId);
|
||||
|
||||
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var group = await sessionStore.GetGroupAsync(groupId);
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var managers = await sessionStore.GetGroupManagersAsync(groupId);
|
||||
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
|
||||
return new WebGroupManagement(group, managers, isOwner);
|
||||
}
|
||||
|
||||
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||
@@ -26,6 +46,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,9 +79,94 @@ 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 UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode)
|
||||
{
|
||||
var batch = await GetBatchForGmAsync(batchId, gmId);
|
||||
if (batch is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, gmId);
|
||||
}
|
||||
|
||||
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
|
||||
{
|
||||
if (coGmTelegramId <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
|
||||
}
|
||||
|
||||
if (ownerTelegramId == coGmTelegramId)
|
||||
{
|
||||
throw new InvalidOperationException("Owner is already a group manager.");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
|
||||
}
|
||||
|
||||
var normalizedName = string.IsNullOrWhiteSpace(displayName)
|
||||
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
|
||||
: displayName.Trim();
|
||||
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
|
||||
? null
|
||||
: telegramUsername.Trim().TrimStart('@');
|
||||
|
||||
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
|
||||
}
|
||||
|
||||
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
|
||||
{
|
||||
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
|
||||
}
|
||||
|
||||
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
|
||||
}
|
||||
|
||||
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
var group = await sessionStore.GetGroupAsync(groupId);
|
||||
return group?.GmTelegramId == gmId;
|
||||
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
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,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
|
||||
|
||||
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.")
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(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 UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
|
||||
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
|
||||
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
|
||||
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
|
||||
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,24 @@ using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId);
|
||||
public sealed record WebGameGroup(
|
||||
Guid Id,
|
||||
long TelegramChatId,
|
||||
string Name,
|
||||
long GmTelegramId,
|
||||
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
|
||||
|
||||
public sealed record WebGroupManager(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string Role,
|
||||
DateTime AddedAt);
|
||||
|
||||
public sealed record WebGroupManagement(
|
||||
WebGameGroup Group,
|
||||
IReadOnlyList<WebGroupManager> Managers,
|
||||
bool CurrentUserIsOwner);
|
||||
public sealed record WebSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
@@ -19,9 +36,33 @@ public sealed record WebSession(
|
||||
long TelegramChatId,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
|
||||
|
||||
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||
internal sealed record WebBatchInfo(
|
||||
Guid BatchId,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
long TelegramChatId,
|
||||
int? BatchMessageId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record WebBatchSessionRow(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int? BatchMessageId,
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -32,7 +73,18 @@ public sealed class SessionService(
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebGameGroup>(
|
||||
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId",
|
||||
"""
|
||||
SELECT g.id,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.name,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
gm.role AS ManagerRole
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE p.telegram_id = @GmId
|
||||
ORDER BY g.name
|
||||
""",
|
||||
new { GmId = gmId })).ToList();
|
||||
}
|
||||
|
||||
@@ -40,8 +92,145 @@ public sealed class SessionService(
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||||
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
|
||||
new { GroupId = groupId });
|
||||
"""
|
||||
SELECT g.id,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.name,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
@OwnerRole AS ManagerRole
|
||||
FROM game_groups g
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
AND p.telegram_id = @TelegramId
|
||||
)
|
||||
""",
|
||||
new { GroupId = groupId, TelegramId = telegramId });
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
AND p.telegram_id = @TelegramId
|
||||
AND gm.role = @OwnerRole
|
||||
)
|
||||
""",
|
||||
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebGroupManager>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
gm.role AS Role,
|
||||
gm.created_at AS AddedAt
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
|
||||
gm.created_at,
|
||||
p.display_name
|
||||
""",
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
|
||||
}
|
||||
|
||||
public async Task AddGroupCoGmAsync(
|
||||
Guid groupId,
|
||||
long ownerTelegramId,
|
||||
long coGmTelegramId,
|
||||
string displayName,
|
||||
string? telegramUsername)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username
|
||||
""",
|
||||
new
|
||||
{
|
||||
TelegramId = coGmTelegramId,
|
||||
DisplayName = displayName,
|
||||
TelegramUsername = telegramUsername
|
||||
},
|
||||
transaction);
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
|
||||
SELECT @GroupId,
|
||||
co_gm.id,
|
||||
@CoGmRole,
|
||||
owner_player.id
|
||||
FROM players co_gm
|
||||
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
|
||||
WHERE co_gm.telegram_id = @CoGmTelegramId
|
||||
ON CONFLICT (group_id, player_id) DO UPDATE
|
||||
SET role = CASE
|
||||
WHEN group_managers.role = @OwnerRole THEN group_managers.role
|
||||
ELSE EXCLUDED.role
|
||||
END,
|
||||
added_by_player_id = EXCLUDED.added_by_player_id
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
OwnerTelegramId = ownerTelegramId,
|
||||
CoGmTelegramId = coGmTelegramId,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||
},
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM group_managers gm
|
||||
USING players p
|
||||
WHERE gm.player_id = p.id
|
||||
AND gm.group_id = @GroupId
|
||||
AND p.telegram_id = @CoGmTelegramId
|
||||
AND gm.role = @CoGmRole
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
CoGmTelegramId = coGmTelegramId,
|
||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||||
@@ -53,7 +242,8 @@ public sealed class SessionService(
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -89,7 +279,8 @@ public sealed class SessionService(
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -115,6 +306,26 @@ 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,
|
||||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||||
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();
|
||||
@@ -126,7 +337,8 @@ public sealed class SessionService(
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -144,6 +356,10 @@ public sealed class SessionService(
|
||||
scheduled_at = @ScheduledAt,
|
||||
join_link = @JoinLink,
|
||||
max_players = @MaxPlayers,
|
||||
one_hour_reminder_processed_at = CASE
|
||||
WHEN scheduled_at <> @ScheduledAt THEN NULL
|
||||
ELSE one_hour_reminder_processed_at
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = @Id AND group_id = @GroupId",
|
||||
new
|
||||
@@ -178,6 +394,13 @@ public sealed class SessionService(
|
||||
|
||||
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId);
|
||||
await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId);
|
||||
}
|
||||
|
||||
if (oldSession.BatchMessageId.HasValue)
|
||||
{
|
||||
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
|
||||
@@ -195,7 +418,8 @@ public sealed class SessionService(
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -282,6 +506,316 @@ 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 UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
|
||||
{
|
||||
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 notification_mode = @NotificationMode,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
NotificationMode = notificationMode.ToDatabaseValue()
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, 0);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
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,
|
||||
s.notification_mode AS NotificationMode
|
||||
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,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
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);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId);
|
||||
await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
s.notification_mode AS NotificationMode
|
||||
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, notification_mode)
|
||||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
|
||||
RETURNING id
|
||||
""",
|
||||
new
|
||||
{
|
||||
BatchId = newBatchId,
|
||||
sourceSession.GroupId,
|
||||
Title = batchTitle,
|
||||
JoinLink = batchJoinLink,
|
||||
ScheduledAt = scheduledAt,
|
||||
Status = SessionStatus.Planned,
|
||||
ThreadId = threadId,
|
||||
sourceSession.MaxPlayers,
|
||||
sourceSession.NotificationMode
|
||||
},
|
||||
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,
|
||||
sourceSessions[0].NotificationMode);
|
||||
}
|
||||
|
||||
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
Guid sessionId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
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 = @Active
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<WebDirectNotificationRecipient>> LoadBatchDirectRecipientsAsync(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
Guid batchId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT DISTINCT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
JOIN sessions s ON s.id = sp.session_id
|
||||
WHERE s.batch_id = @BatchId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
}
|
||||
|
||||
private async Task SendDirectNotificationsAsync(
|
||||
IEnumerable<WebDirectNotificationRecipient> recipients,
|
||||
string htmlText,
|
||||
string notificationKind,
|
||||
Guid entityId)
|
||||
{
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: recipient.TelegramId,
|
||||
text: htmlText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})",
|
||||
notificationKind,
|
||||
entityId,
|
||||
recipient.TelegramId,
|
||||
recipient.DisplayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
|
||||
{
|
||||
try
|
||||
@@ -318,4 +852,30 @@ 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,
|
||||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||||
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.2.0
|
||||
GM-Relay Design System v1.6.0
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Domain;
|
||||
|
||||
public sealed class SessionNotificationModeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SessionNotificationMode.GroupAndDirect, true)]
|
||||
[InlineData(SessionNotificationMode.GroupOnly, false)]
|
||||
public void ShouldSendDirectMessages_ReturnsExpectedDecision(
|
||||
SessionNotificationMode mode,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, mode.ShouldSendDirectMessages());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using GmRelay.Web.Services;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
@@ -27,6 +28,34 @@ public sealed class AuthorizedSessionServiceTests
|
||||
Assert.Equal("Session A", sessions[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenUserIsCoGm()
|
||||
{
|
||||
var ownerId = 1001L;
|
||||
var coGmId = 2002L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", ownerId)
|
||||
],
|
||||
sessions:
|
||||
[
|
||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||
],
|
||||
managers:
|
||||
[
|
||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
|
||||
|
||||
Assert.NotNull(sessions);
|
||||
Assert.Single(sessions);
|
||||
Assert.Equal("Session A", sessions[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
|
||||
{
|
||||
@@ -162,15 +191,292 @@ 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 UpdateBatchDetailsForGmAsync_UpdatesBatch_WhenUserIsCoGm()
|
||||
{
|
||||
var ownerId = 1001L;
|
||||
var coGmId = 2002L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var batchId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", ownerId)
|
||||
],
|
||||
sessions:
|
||||
[
|
||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||
],
|
||||
managers:
|
||||
[
|
||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
|
||||
|
||||
Assert.True(store.UpdateBatchDetailsCalled);
|
||||
Assert.Equal(batchId, store.LastUpdatedBatchId);
|
||||
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddCoGmForOwnerAsync_AddsCoGm_WhenUserIsOwner()
|
||||
{
|
||||
var ownerId = 1001L;
|
||||
var coGmId = 2002L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", ownerId)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
|
||||
|
||||
Assert.True(store.AddCoGmCalled);
|
||||
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
|
||||
Assert.Equal(coGmId, store.LastAddedCoGmTelegramId);
|
||||
Assert.Equal("Assistant GM", store.LastAddedCoGmDisplayName);
|
||||
Assert.Equal("assistant", store.LastAddedCoGmUsername);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddCoGmForOwnerAsync_Throws_WhenUserIsCoGm()
|
||||
{
|
||||
var ownerId = 1001L;
|
||||
var coGmId = 2002L;
|
||||
var newCoGmId = 3003L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", ownerId)
|
||||
],
|
||||
managers:
|
||||
[
|
||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||
Assert.False(store.AddCoGmCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveCoGmForOwnerAsync_RemovesCoGm_WhenUserIsOwner()
|
||||
{
|
||||
var ownerId = 1001L;
|
||||
var coGmId = 2002L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", ownerId)
|
||||
],
|
||||
managers:
|
||||
[
|
||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
|
||||
|
||||
Assert.True(store.RemoveCoGmCalled);
|
||||
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
|
||||
Assert.Equal(coGmId, store.LastRemovedCoGmTelegramId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBatchNotificationModeForGmAsync_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.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||
Assert.False(store.UpdateBatchNotificationModeCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBatchNotificationModeForGmAsync_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.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
|
||||
|
||||
Assert.True(store.UpdateBatchNotificationModeCalled);
|
||||
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
|
||||
Assert.Equal(groupId, store.LastUpdatedNotificationGroupId);
|
||||
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastUpdatedNotificationMode);
|
||||
}
|
||||
|
||||
[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
|
||||
IEnumerable<WebSession>? sessions = null,
|
||||
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore
|
||||
{
|
||||
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
|
||||
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
|
||||
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
|
||||
|
||||
public bool UpdateCalled { get; private set; }
|
||||
public bool PromoteCalled { get; private set; }
|
||||
public bool UpdateBatchDetailsCalled { get; private set; }
|
||||
public bool UpdateBatchNotificationModeCalled { get; private set; }
|
||||
public bool RescheduleBatchCalled { get; private set; }
|
||||
public bool CloneBatchCalled { get; private set; }
|
||||
public bool AddCoGmCalled { get; private set; }
|
||||
public bool RemoveCoGmCalled { get; private set; }
|
||||
public Guid? LastUpdatedSessionId { get; private set; }
|
||||
public Guid? LastUpdatedGroupId { get; private set; }
|
||||
public string? LastUpdatedTitle { get; private set; }
|
||||
@@ -179,9 +485,29 @@ 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? LastUpdatedNotificationBatchId { get; private set; }
|
||||
public Guid? LastUpdatedNotificationGroupId { get; private set; }
|
||||
public SessionNotificationMode? LastUpdatedNotificationMode { 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 Guid? LastAddedCoGmGroupId { get; private set; }
|
||||
public long? LastAddedCoGmTelegramId { get; private set; }
|
||||
public string? LastAddedCoGmDisplayName { get; private set; }
|
||||
public string? LastAddedCoGmUsername { get; private set; }
|
||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||
public long? LastRemovedCoGmTelegramId { get; private set; }
|
||||
|
||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
|
||||
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
|
||||
|
||||
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||
{
|
||||
@@ -189,6 +515,36 @@ public sealed class AuthorizedSessionServiceTests
|
||||
return Task.FromResult(group);
|
||||
}
|
||||
|
||||
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||
Task.FromResult(IsManager(groupId, telegramId));
|
||||
|
||||
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
|
||||
Task.FromResult(IsOwner(groupId, telegramId));
|
||||
|
||||
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||||
{
|
||||
if (!groupsById.TryGetValue(groupId, out var group))
|
||||
{
|
||||
return Task.FromResult(new List<WebGroupManager>());
|
||||
}
|
||||
|
||||
var result = new List<WebGroupManager>
|
||||
{
|
||||
new(group.GmTelegramId, "Owner GM", null, GroupManagerRoleExtensions.OwnerValue, DateTime.UtcNow)
|
||||
};
|
||||
|
||||
result.AddRange(managers
|
||||
.Where(manager => manager.GroupId == groupId)
|
||||
.Select(manager => new WebGroupManager(
|
||||
manager.TelegramId,
|
||||
$"Co-GM {manager.TelegramId}",
|
||||
null,
|
||||
manager.Role.ToDatabaseValue(),
|
||||
DateTime.UtcNow)));
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
|
||||
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
|
||||
|
||||
@@ -198,6 +554,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 +596,77 @@ 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 UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
|
||||
{
|
||||
UpdateBatchNotificationModeCalled = true;
|
||||
LastUpdatedNotificationBatchId = batchId;
|
||||
LastUpdatedNotificationGroupId = groupId;
|
||||
LastUpdatedNotificationMode = notificationMode;
|
||||
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));
|
||||
}
|
||||
|
||||
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
|
||||
{
|
||||
AddCoGmCalled = true;
|
||||
LastAddedCoGmGroupId = groupId;
|
||||
LastAddedCoGmTelegramId = coGmTelegramId;
|
||||
LastAddedCoGmDisplayName = displayName;
|
||||
LastAddedCoGmUsername = telegramUsername;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
|
||||
{
|
||||
RemoveCoGmCalled = true;
|
||||
LastRemovedCoGmGroupId = groupId;
|
||||
LastRemovedCoGmTelegramId = coGmTelegramId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool IsManager(Guid groupId, long telegramId) =>
|
||||
IsOwner(groupId, telegramId) ||
|
||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||
|
||||
private bool IsOwner(Guid groupId, long telegramId) =>
|
||||
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
|
||||
}
|
||||
|
||||
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
|
||||
}
|
||||
|
||||
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user