Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1ec688ec8 | |||
| 2529df4157 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.5.0
|
VERSION: 1.7.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.5.0</Version>
|
<Version>1.7.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.5.0`.
|
**Текущая версия:** `v1.7.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
||||||
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
||||||
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
|
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
|
||||||
|
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
|
||||||
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
||||||
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
|
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
|
||||||
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
|
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
### 🌐 Web Dashboard (Blazor Server)
|
### 🌐 Web Dashboard (Blazor Server)
|
||||||
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
||||||
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
||||||
|
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
|
||||||
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
||||||
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
||||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||||
@@ -103,7 +105,7 @@ docker compose up -d
|
|||||||
* `Закрепление сообщений` — рекомендуется.
|
* `Закрепление сообщений` — рекомендуется.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
|
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,8 +127,22 @@ docker compose up -d
|
|||||||
|
|
||||||
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
||||||
|
|
||||||
|
### Делегирование управления
|
||||||
|
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
|
||||||
|
|
||||||
|
### Перенос сессии голосованием
|
||||||
|
Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном:
|
||||||
|
|
||||||
|
```text
|
||||||
|
25.04.2026 19:30
|
||||||
|
26.04.2026 18:00
|
||||||
|
Дедлайн: 25.04.2026 12:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
|
||||||
|
|
||||||
### Bulk-операции в Web Dashboard
|
### Bulk-операции в Web Dashboard
|
||||||
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может:
|
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
|
||||||
- обновить общий `title` и `link` сразу у всех сессий batch;
|
- обновить общий `title` и `link` сразу у всех сессий batch;
|
||||||
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
||||||
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
|
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
|
||||||
@@ -138,7 +154,7 @@ docker compose up -d
|
|||||||
|
|
||||||
### Другие команды
|
### Другие команды
|
||||||
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
||||||
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
|
- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени.
|
||||||
- `/deletesession` — Удалить сессию.
|
- `/deletesession` — Удалить сессию.
|
||||||
- `/exportcalendar` — Получить `.ics` файл с играми.
|
- `/exportcalendar` — Получить `.ics` файл с играми.
|
||||||
- `/help` — Справка по формату.
|
- `/help` — Справка по формату.
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.5.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.7.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- gmrelay
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.5.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.7.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed record CancelSessionCommand(
|
|||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// DTOs for AOT compilation
|
// DTOs for AOT compilation
|
||||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId, string NotificationMode);
|
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
|
||||||
|
|
||||||
public sealed class CancelSessionHandler(
|
public sealed class CancelSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -29,13 +29,23 @@ public sealed class CancelSessionHandler(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
// 1. Проверяем, что запрос делает ГМ данной сессии
|
// 1. Проверяем, что запрос делает управляющий данной группы.
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
||||||
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode
|
"""
|
||||||
FROM sessions s
|
SELECT s.title AS Title,
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
s.batch_id AS BatchId,
|
||||||
WHERE s.id = @SessionId",
|
s.notification_mode AS NotificationMode,
|
||||||
new { command.SessionId }, transaction);
|
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)
|
if (session == null)
|
||||||
{
|
{
|
||||||
@@ -43,9 +53,9 @@ public sealed class CancelSessionHandler(
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ using Telegram.Bot.Types;
|
|||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||||
|
|
||||||
public sealed class CreateSessionHandler(
|
public sealed class CreateSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient botClient,
|
ITelegramBotClient botClient,
|
||||||
@@ -74,16 +76,64 @@ public sealed class CreateSessionHandler(
|
|||||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
SELECT g.id AS GroupId,
|
||||||
VALUES (@ChatId, @ChatName, @GmId)
|
EXISTS (
|
||||||
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
SELECT 1
|
||||||
RETURNING id;
|
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);
|
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;
|
int? messageThreadId = null;
|
||||||
if (message.Chat.IsForum)
|
if (message.Chat.IsForum)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
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);
|
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||||
|
|
||||||
public sealed class PromoteWaitlistedPlayerHandler(
|
public sealed class PromoteWaitlistedPlayerHandler(
|
||||||
@@ -34,13 +34,18 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
SELECT s.title AS Title,
|
SELECT s.title AS Title,
|
||||||
s.batch_id AS BatchId,
|
s.batch_id AS BatchId,
|
||||||
s.max_players AS MaxPlayers,
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
""",
|
""",
|
||||||
new { command.SessionId },
|
new { command.SessionId, command.TelegramUserId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
@@ -50,10 +55,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.GmId != command.TelegramUserId)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
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(
|
public sealed class DeleteSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(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>(
|
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
|
SELECT s.title AS Title,
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
s.batch_id AS BatchId,
|
||||||
WHERE s.id = @SessionId",
|
s.thread_id AS ThreadId,
|
||||||
new { command.SessionId }, transaction);
|
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)
|
if (session == null)
|
||||||
{
|
{
|
||||||
@@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler(
|
|||||||
return;
|
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;
|
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,
|
@"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 = @Active) as PlayerCount,
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_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()
|
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",
|
ORDER BY s.scheduled_at ASC",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
ChatId = command.ChatId,
|
ChatId = command.ChatId,
|
||||||
|
command.TelegramUserId,
|
||||||
Cancelled = SessionStatus.Cancelled,
|
Cancelled = SessionStatus.Cancelled,
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
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";
|
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
var isGm = command.TelegramUserId == sessionsList.First().GmId;
|
var canManage = sessionsList.First().CanManage;
|
||||||
var keyboard = isGm
|
var keyboard = canManage
|
||||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
|
|||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
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(
|
public sealed class ListSessionsHandler(
|
||||||
NpgsqlDataSource dataSource,
|
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,
|
@"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 = @Active) as PlayerCount,
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_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()
|
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",
|
ORDER BY s.scheduled_at ASC",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
ChatId = message.Chat.Id,
|
ChatId = message.Chat.Id,
|
||||||
|
TelegramUserId = message.From?.Id,
|
||||||
Cancelled = SessionStatus.Cancelled,
|
Cancelled = SessionStatus.Cancelled,
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
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";
|
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
var isGm = message.From?.Id == sessionsList.First().GmId;
|
var canManage = sessionsList.First().CanManage;
|
||||||
var keyboard = isGm
|
var keyboard = canManage
|
||||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
+140
-37
@@ -25,7 +25,8 @@ internal sealed record VoteParticipantDto(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
/// Handles text input from the GM who has an AwaitingTime proposal.
|
||||||
/// Parses the new time, creates a voting message, and tags all participants.
|
/// Parses reschedule options with a voting deadline, creates a voting message,
|
||||||
|
/// and tags all participants.
|
||||||
/// If no participants are registered, reschedules immediately.
|
/// If no participants are registered, reschedules immediately.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HandleRescheduleTimeInputHandler(
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
@@ -62,6 +63,13 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
WHERE rp.proposed_by = @GmId
|
WHERE rp.proposed_by = @GmId
|
||||||
AND rp.status = 'AwaitingTime'
|
AND rp.status = 'AwaitingTime'
|
||||||
AND g.telegram_chat_id = @ChatId
|
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
|
ORDER BY rp.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
@@ -70,26 +78,17 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
if (proposal is null)
|
if (proposal is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// 2. Parse the new time
|
// 2. Parse voting input
|
||||||
if (!MoscowTime.TryParseMoscow(text, out var newTime))
|
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
|
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTime <= DateTimeOffset.UtcNow)
|
|
||||||
{
|
|
||||||
await bot.SendMessage(
|
|
||||||
chatId: chatId,
|
|
||||||
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
|
|
||||||
cancellationToken: ct);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Load participants (non-GM) signed up for this session
|
// 3. Load participants (non-GM) signed up for this session
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
"""
|
"""
|
||||||
@@ -108,35 +107,56 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
// 4. If no participants — reschedule immediately
|
// 4. If no participants — reschedule immediately
|
||||||
if (participants.Count == 0)
|
if (participants.Count == 0)
|
||||||
{
|
{
|
||||||
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
|
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create voting message
|
// 5. Create voting message
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
var options = votingInput.Options
|
||||||
|
.Select((proposedAt, index) => new RescheduleOptionDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index + 1,
|
||||||
|
proposedAt))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// Update proposal with proposed time and Voting status
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE reschedule_proposals
|
UPDATE reschedule_proposals
|
||||||
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
|
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
|
||||||
WHERE id = @Id
|
WHERE id = @Id
|
||||||
""",
|
""",
|
||||||
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
|
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||||
|
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
option.OptionId,
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
option.ProposedAt,
|
||||||
|
option.DisplayOrder
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// Build voting message text
|
var voteText = BuildVotingMessage(
|
||||||
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
var keyboard = new InlineKeyboardMarkup([
|
votingInput.Deadline,
|
||||||
[
|
options,
|
||||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
|
participants,
|
||||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
|
[]);
|
||||||
]
|
var keyboard = BuildVotingKeyboard(options);
|
||||||
]);
|
|
||||||
|
|
||||||
var voteMsg = await bot.SendMessage(
|
var voteMsg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
@@ -148,12 +168,18 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
{
|
{
|
||||||
|
var optionsText = string.Join(
|
||||||
|
"\n",
|
||||||
|
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||||
var directText = $"""
|
var directText = $"""
|
||||||
🔄 <b>Голосование за перенос сессии</b>
|
🔄 <b>Голосование за перенос сессии</b>
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||||
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК)
|
🗳 Варианты:
|
||||||
|
{optionsText}
|
||||||
|
|
||||||
|
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
|
||||||
|
|
||||||
Проголосуйте кнопкой в групповом сообщении.
|
Проголосуйте кнопкой в групповом сообщении.
|
||||||
""";
|
""";
|
||||||
@@ -173,7 +199,12 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
||||||
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
||||||
|
|
||||||
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id);
|
logger.LogInformation(
|
||||||
|
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
||||||
|
proposal.SessionId,
|
||||||
|
proposal.Id,
|
||||||
|
options.Count,
|
||||||
|
votingInput.Deadline);
|
||||||
|
|
||||||
// Delete GM's time input message
|
// Delete GM's time input message
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||||
@@ -219,33 +250,105 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static string BuildVotingMessage(
|
internal static string BuildVotingMessage(
|
||||||
string title, DateTime currentTime, DateTimeOffset newTime,
|
string title,
|
||||||
|
DateTime currentTime,
|
||||||
|
DateTimeOffset deadline,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> options,
|
||||||
IReadOnlyList<VoteParticipantDto> participants,
|
IReadOnlyList<VoteParticipantDto> participants,
|
||||||
IReadOnlyCollection<Guid> approvedPlayerIds)
|
IReadOnlyList<RescheduleOptionVoteDto> votes)
|
||||||
{
|
{
|
||||||
|
var votesByOption = votes
|
||||||
|
.GroupBy(v => v.OptionId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
|
||||||
|
var pendingParticipants = participants
|
||||||
|
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
|
||||||
|
.Select(FormatParticipantName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var lines = new List<string>
|
var lines = new List<string>
|
||||||
{
|
{
|
||||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
||||||
"",
|
"",
|
||||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||||
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
|
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
||||||
"",
|
"",
|
||||||
"Для переноса нужно согласие всех участников:"
|
"Выберите один из вариантов:"
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var p in participants)
|
foreach (var option in options.OrderBy(x => x.DisplayOrder))
|
||||||
{
|
{
|
||||||
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
|
||||||
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
|
lines.Add(
|
||||||
lines.Add($" {icon} {name}");
|
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
|
||||||
|
|
||||||
|
if (optionVotes.Count > 0)
|
||||||
|
{
|
||||||
|
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingParticipants.Count > 0)
|
||||||
|
{
|
||||||
|
lines.Add("");
|
||||||
|
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.Add("");
|
lines.Add("");
|
||||||
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count} ✅");
|
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
|
||||||
|
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
|
||||||
|
|
||||||
return string.Join("\n", lines);
|
return string.Join("\n", lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
|
||||||
|
{
|
||||||
|
return new InlineKeyboardMarkup(
|
||||||
|
options
|
||||||
|
.OrderBy(option => option.DisplayOrder)
|
||||||
|
.Select(option => new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData(
|
||||||
|
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
|
||||||
|
$"reschedule_vote:{option.OptionId}")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatParticipantName(VoteParticipantDto participant)
|
||||||
|
{
|
||||||
|
return participant.TelegramUsername is { Length: > 0 } username
|
||||||
|
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
||||||
|
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
|
||||||
|
{
|
||||||
|
return vote.TelegramUsername is { Length: > 0 } username
|
||||||
|
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
||||||
|
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatVoteCount(int count)
|
||||||
|
{
|
||||||
|
var modulo100 = count % 100;
|
||||||
|
var modulo10 = count % 10;
|
||||||
|
var word = modulo100 is >= 11 and <= 14
|
||||||
|
? "голосов"
|
||||||
|
: modulo10 switch
|
||||||
|
{
|
||||||
|
1 => "голос",
|
||||||
|
>= 2 and <= 4 => "голоса",
|
||||||
|
_ => "голосов"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{count} {word}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatButtonTime(DateTimeOffset utc)
|
||||||
|
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
|
||||||
|
"dd.MM HH:mm",
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+85
-269
@@ -1,16 +1,12 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
public sealed record HandleRescheduleVoteCommand(
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
Guid ProposalId,
|
Guid OptionId,
|
||||||
string Vote,
|
|
||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
@@ -19,20 +15,13 @@ public sealed record HandleRescheduleVoteCommand(
|
|||||||
internal sealed record VoteProposalDto(
|
internal sealed record VoteProposalDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
DateTime ProposedAt,
|
DateTimeOffset VotingDeadlineAt,
|
||||||
string Title,
|
string Title,
|
||||||
DateTime CurrentScheduledAt,
|
DateTime CurrentScheduledAt);
|
||||||
Guid BatchId,
|
|
||||||
string SessionStatus,
|
|
||||||
long TelegramChatId,
|
|
||||||
int? ConfirmationMessageId,
|
|
||||||
int? BatchMessageId,
|
|
||||||
string NotificationMode);
|
|
||||||
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
DirectSessionNotificationSender directSender,
|
|
||||||
ILogger<HandleRescheduleVoteHandler> logger)
|
ILogger<HandleRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
@@ -44,21 +33,15 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT rp.id AS Id,
|
SELECT rp.id AS Id,
|
||||||
rp.session_id AS SessionId,
|
rp.session_id AS SessionId,
|
||||||
rp.proposed_at AS ProposedAt,
|
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||||
s.title AS Title,
|
s.title AS Title,
|
||||||
s.scheduled_at AS CurrentScheduledAt,
|
s.scheduled_at AS CurrentScheduledAt
|
||||||
s.batch_id AS BatchId,
|
FROM reschedule_options ro
|
||||||
s.status AS SessionStatus,
|
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||||
s.confirmation_message_id AS ConfirmationMessageId,
|
|
||||||
s.batch_message_id AS BatchMessageId,
|
|
||||||
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 sessions s ON s.id = rp.session_id
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
||||||
WHERE rp.id = @ProposalId AND rp.status = 'Voting'
|
|
||||||
""",
|
""",
|
||||||
new { command.ProposalId },
|
new { command.OptionId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (proposal is null)
|
if (proposal is null)
|
||||||
@@ -70,6 +53,16 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
command.CallbackQueryId,
|
||||||
|
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
||||||
|
showAlert: true,
|
||||||
|
cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||||
"""
|
"""
|
||||||
SELECT p.id
|
SELECT p.id
|
||||||
@@ -94,268 +87,91 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
|
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
||||||
VALUES (@ProposalId, @PlayerId, @Vote)
|
VALUES (@ProposalId, @PlayerId, @OptionId)
|
||||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||||
SET vote = EXCLUDED.vote,
|
SET option_id = EXCLUDED.option_id,
|
||||||
voted_at = now()
|
voted_at = now()
|
||||||
""",
|
""",
|
||||||
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
new
|
||||||
|
{
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
PlayerId = playerId.Value,
|
||||||
|
command.OptionId
|
||||||
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
? new List<VoteParticipantDto>()
|
|
||||||
: (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? new HashSet<Guid>()
|
|
||||||
: (await connection.QueryAsync<Guid>(
|
|
||||||
"""
|
|
||||||
SELECT player_id
|
|
||||||
FROM reschedule_votes
|
|
||||||
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
|
||||||
""",
|
|
||||||
new { command.ProposalId },
|
|
||||||
transaction)).ToHashSet();
|
|
||||||
|
|
||||||
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
|
|
||||||
|
|
||||||
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 },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
|
|
||||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
|
||||||
new { command.TelegramUserId });
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await bot.EditMessageText(
|
|
||||||
chatId: command.ChatId,
|
|
||||||
messageId: command.MessageId,
|
|
||||||
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to update vote message after rejection");
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
"""
|
|
||||||
UPDATE sessions
|
|
||||||
SET scheduled_at = @NewTime,
|
|
||||||
status = @Status,
|
|
||||||
confirmation_message_id = NULL,
|
|
||||||
link_message_id = NULL,
|
|
||||||
one_hour_reminder_processed_at = NULL,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = @SessionId
|
|
||||||
""",
|
|
||||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
|
|
||||||
new { Id = command.ProposalId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (decision.ShouldResetParticipantRsvps)
|
|
||||||
{
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
UPDATE session_participants
|
|
||||||
SET rsvp_status = 'Pending',
|
|
||||||
responded_at = NULL
|
|
||||||
WHERE session_id = @SessionId AND is_gm = false
|
|
||||||
AND registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await bot.EditMessageText(
|
|
||||||
chatId: command.ChatId,
|
|
||||||
messageId: command.MessageId,
|
|
||||||
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to update vote message after approval");
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
newTime,
|
|
||||||
command.ProposalId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
|
||||||
proposal.Title,
|
|
||||||
proposal.CurrentScheduledAt,
|
|
||||||
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
|
||||||
participants,
|
|
||||||
approvedPlayerIds);
|
|
||||||
|
|
||||||
var keyboard = new InlineKeyboardMarkup([
|
|
||||||
[
|
|
||||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
|
|
||||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await bot.EditMessageText(
|
|
||||||
chatId: command.ChatId,
|
|
||||||
messageId: command.MessageId,
|
|
||||||
text: voteText,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: keyboard,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to update vote message with progress");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
SELECT p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
p.telegram_id AS TelegramId
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
AND sp.registration_status = @Active
|
AND sp.registration_status = @Active
|
||||||
|
ORDER BY p.display_name
|
||||||
""",
|
""",
|
||||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
transaction)).ToList();
|
transaction)).ToList();
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||||
{
|
"""
|
||||||
|
SELECT id AS OptionId,
|
||||||
|
display_order AS DisplayOrder,
|
||||||
|
proposed_at AS ProposedAt
|
||||||
|
FROM reschedule_options
|
||||||
|
WHERE proposal_id = @ProposalId
|
||||||
|
ORDER BY display_order
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
||||||
|
"""
|
||||||
|
SELECT rov.option_id AS OptionId,
|
||||||
|
p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername
|
||||||
|
FROM reschedule_option_votes rov
|
||||||
|
JOIN players p ON p.id = rov.player_id
|
||||||
|
WHERE rov.proposal_id = @ProposalId
|
||||||
|
ORDER BY rov.voted_at, p.display_name
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
|
proposal.VotingDeadlineAt,
|
||||||
|
options,
|
||||||
|
participants,
|
||||||
|
votes);
|
||||||
|
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await bot.EditMessageText(
|
||||||
|
chatId: command.ChatId,
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
messageId: command.MessageId,
|
||||||
"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",
|
text: voteText,
|
||||||
new { proposal.BatchId })).ToList();
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
cancellationToken: ct);
|
||||||
"""
|
|
||||||
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 { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
|
||||||
{
|
|
||||||
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
|
||||||
|
|
||||||
await bot.EditMessageText(
|
|
||||||
chatId: proposal.TelegramChatId,
|
|
||||||
messageId: proposal.BatchMessageId.Value,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await bot.SendMessage(
|
|
||||||
chatId: proposal.TelegramChatId,
|
|
||||||
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
command.CallbackQueryId,
|
||||||
|
"Ваш голос учтён. До дедлайна его можно изменить.",
|
||||||
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ public sealed record InitiateRescheduleCommand(
|
|||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the "⏰ Перенести" button press from the batch message.
|
/// Handles the "⏰ Перенести" button press from the batch message.
|
||||||
/// Creates a reschedule proposal in AwaitingTime status and prompts
|
/// Creates a reschedule proposal in AwaitingTime status and prompts
|
||||||
/// the GM to enter the new time via a regular text message.
|
/// the GM to enter 2-3 new time options and a voting deadline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InitiateRescheduleHandler(
|
public sealed class InitiateRescheduleHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
// 1. Verify GM ownership
|
// 1. Verify group management access.
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
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)
|
if (session is null)
|
||||||
{
|
{
|
||||||
@@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.GmId != command.TelegramUserId)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +92,20 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
|
|
||||||
// 4. Prompt GM in chat
|
// 4. Prompt GM in chat
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
|
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
|
||||||
|
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: command.ChatId,
|
chatId: command.ChatId,
|
||||||
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
|
text: $"""
|
||||||
|
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
<code>25.04.2026 19:30
|
||||||
|
26.04.2026 18:00
|
||||||
|
Дедлайн: 25.04.2026 12:00</code>
|
||||||
|
|
||||||
|
Дедлайн должен быть в будущем и раньше первого предложенного времени.
|
||||||
|
""",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,27 +9,57 @@ internal enum RescheduleVoteOutcome
|
|||||||
|
|
||||||
internal sealed record RescheduleVoteDecision(
|
internal sealed record RescheduleVoteDecision(
|
||||||
RescheduleVoteOutcome Outcome,
|
RescheduleVoteOutcome Outcome,
|
||||||
string CallbackText,
|
string Reason,
|
||||||
bool ShouldRescheduleSession,
|
Guid? SelectedOptionId = null,
|
||||||
bool ShouldResetParticipantRsvps);
|
string CallbackText = "",
|
||||||
|
bool ShouldRescheduleSession = false,
|
||||||
|
bool ShouldResetParticipantRsvps = false);
|
||||||
|
|
||||||
internal static class RescheduleVoteRules
|
internal static class RescheduleVoteRules
|
||||||
{
|
{
|
||||||
|
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
|
||||||
|
{
|
||||||
|
var maxVotes = voteCounts.Count == 0 ? 0 : voteCounts.Max(x => x.VoteCount);
|
||||||
|
if (maxVotes == 0)
|
||||||
|
{
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
RescheduleVoteOutcome.Rejected,
|
||||||
|
"Никто не проголосовал до дедлайна, перенос не применяется.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var winners = voteCounts.Where(x => x.VoteCount == maxVotes).ToList();
|
||||||
|
if (winners.Count > 1)
|
||||||
|
{
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
RescheduleVoteOutcome.Rejected,
|
||||||
|
"Голоса разделились поровну, перенос не применяется.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
RescheduleVoteOutcome.Approved,
|
||||||
|
"Победил вариант с большинством голосов.",
|
||||||
|
winners[0].OptionId,
|
||||||
|
ShouldRescheduleSession: true,
|
||||||
|
ShouldResetParticipantRsvps: true);
|
||||||
|
}
|
||||||
|
|
||||||
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
|
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
|
||||||
{
|
{
|
||||||
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return new RescheduleVoteDecision(
|
return new RescheduleVoteDecision(
|
||||||
Outcome: RescheduleVoteOutcome.Rejected,
|
Outcome: RescheduleVoteOutcome.Rejected,
|
||||||
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.",
|
Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.",
|
||||||
ShouldRescheduleSession: false,
|
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.");
|
||||||
ShouldResetParticipantRsvps: false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var everyoneApproved = approvedParticipants == totalParticipants;
|
var everyoneApproved = approvedParticipants == totalParticipants;
|
||||||
|
|
||||||
return new RescheduleVoteDecision(
|
return new RescheduleVoteDecision(
|
||||||
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
|
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
|
||||||
|
Reason: everyoneApproved
|
||||||
|
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b."
|
||||||
|
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.",
|
||||||
CallbackText: everyoneApproved
|
CallbackText: everyoneApproved
|
||||||
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
|
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
|
||||||
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
|
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
|
||||||
|
|||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal sealed record DueRescheduleProposalDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SessionId,
|
||||||
|
DateTimeOffset VotingDeadlineAt,
|
||||||
|
string Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
Guid BatchId,
|
||||||
|
int? BatchMessageId,
|
||||||
|
int? VoteMessageId,
|
||||||
|
long TelegramChatId,
|
||||||
|
string NotificationMode);
|
||||||
|
|
||||||
|
public sealed class RescheduleVotingDeadlineService(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
DirectSessionNotificationSender directSender,
|
||||||
|
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessDueProposals(stoppingToken);
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
await ProcessDueProposals(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessDueProposals(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var proposalIds = (await connection.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM reschedule_proposals
|
||||||
|
WHERE status = 'Voting'
|
||||||
|
AND voting_deadline_at IS NOT NULL
|
||||||
|
AND voting_deadline_at <= now()
|
||||||
|
ORDER BY voting_deadline_at
|
||||||
|
LIMIT 25
|
||||||
|
""")).ToList();
|
||||||
|
|
||||||
|
foreach (var proposalId in proposalIds)
|
||||||
|
{
|
||||||
|
await FinalizeProposal(proposalId, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to process due reschedule voting proposals");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
|
||||||
|
"""
|
||||||
|
SELECT rp.id AS Id,
|
||||||
|
rp.session_id AS SessionId,
|
||||||
|
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||||
|
rp.vote_message_id AS VoteMessageId,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS CurrentScheduledAt,
|
||||||
|
s.batch_id AS BatchId,
|
||||||
|
s.batch_message_id AS BatchMessageId,
|
||||||
|
s.notification_mode AS NotificationMode,
|
||||||
|
g.telegram_chat_id AS TelegramChatId
|
||||||
|
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.id = @ProposalId
|
||||||
|
AND rp.status = 'Voting'
|
||||||
|
AND rp.voting_deadline_at IS NOT NULL
|
||||||
|
AND rp.voting_deadline_at <= now()
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposalId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
ORDER BY p.display_name
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||||
|
"""
|
||||||
|
SELECT id AS OptionId,
|
||||||
|
display_order AS DisplayOrder,
|
||||||
|
proposed_at AS ProposedAt
|
||||||
|
FROM reschedule_options
|
||||||
|
WHERE proposal_id = @ProposalId
|
||||||
|
ORDER BY display_order
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
||||||
|
"""
|
||||||
|
SELECT rov.option_id AS OptionId,
|
||||||
|
p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername
|
||||||
|
FROM reschedule_option_votes rov
|
||||||
|
JOIN players p ON p.id = rov.player_id
|
||||||
|
WHERE rov.proposal_id = @ProposalId
|
||||||
|
ORDER BY rov.voted_at, p.display_name
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var voteCounts = options
|
||||||
|
.Select(option => new RescheduleOptionVoteCount(
|
||||||
|
option.OptionId,
|
||||||
|
votes.Count(vote => vote.OptionId == option.OptionId)))
|
||||||
|
.ToList();
|
||||||
|
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
|
||||||
|
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
|
||||||
|
? options.Single(x => x.OptionId == selectedOptionId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (selectedOption is not null)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET scheduled_at = @NewTime,
|
||||||
|
status = @Status,
|
||||||
|
confirmation_message_id = NULL,
|
||||||
|
link_message_id = NULL,
|
||||||
|
one_hour_reminder_processed_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
""",
|
||||||
|
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE session_participants
|
||||||
|
SET rsvp_status = 'Pending',
|
||||||
|
responded_at = NULL
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND is_gm = false
|
||||||
|
AND registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE reschedule_proposals
|
||||||
|
SET status = 'Approved',
|
||||||
|
selected_option_id = @SelectedOptionId,
|
||||||
|
proposed_at = @ProposedAt
|
||||||
|
WHERE id = @ProposalId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
SelectedOptionId = selectedOption.OptionId,
|
||||||
|
ProposedAt = selectedOption.ProposedAt
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
var directRecipients = participants
|
||||||
|
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
|
||||||
|
|
||||||
|
if (selectedOption is not null)
|
||||||
|
{
|
||||||
|
await TryUpdateBatchMessage(proposal, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||||
|
if (mode.ShouldSendDirectMessages())
|
||||||
|
{
|
||||||
|
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
|
||||||
|
proposal.Id,
|
||||||
|
proposal.SessionId,
|
||||||
|
decision.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryUpdateVoteMessage(
|
||||||
|
DueRescheduleProposalDto proposal,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> options,
|
||||||
|
IReadOnlyList<VoteParticipantDto> participants,
|
||||||
|
IReadOnlyList<RescheduleOptionVoteDto> votes,
|
||||||
|
RescheduleVoteDecision decision,
|
||||||
|
RescheduleOptionDto? selectedOption,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (proposal.VoteMessageId is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resultText = selectedOption is not null
|
||||||
|
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||||
|
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
|
||||||
|
|
||||||
|
var text = $"""
|
||||||
|
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
|
proposal.VotingDeadlineAt,
|
||||||
|
options,
|
||||||
|
participants,
|
||||||
|
votes)}
|
||||||
|
|
||||||
|
{resultText}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: proposal.TelegramChatId,
|
||||||
|
messageId: proposal.VoteMessageId.Value,
|
||||||
|
text: text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
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 { proposal.BatchId })).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 { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
|
if (proposal.BatchMessageId.HasValue)
|
||||||
|
{
|
||||||
|
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
||||||
|
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: proposal.TelegramChatId,
|
||||||
|
messageId: proposal.BatchMessageId.Value,
|
||||||
|
text: renderResult.Text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: proposal.TelegramChatId,
|
||||||
|
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendDirectResult(
|
||||||
|
DueRescheduleProposalDto proposal,
|
||||||
|
IReadOnlyList<DirectNotificationRecipient> recipients,
|
||||||
|
RescheduleVoteDecision decision,
|
||||||
|
RescheduleOptionDto? selectedOption,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var htmlText = selectedOption is not null
|
||||||
|
? $"""
|
||||||
|
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||||
|
|
||||||
|
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||||
|
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
|
||||||
|
"""
|
||||||
|
: $"""
|
||||||
|
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||||
|
|
||||||
|
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||||
|
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
|
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await directSender.SendAsync(
|
||||||
|
recipients,
|
||||||
|
htmlText,
|
||||||
|
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
|
||||||
|
proposal.SessionId,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal sealed record RescheduleVotingInput(
|
||||||
|
IReadOnlyList<DateTimeOffset> Options,
|
||||||
|
DateTimeOffset Deadline)
|
||||||
|
{
|
||||||
|
private static readonly Regex DateTimePattern = new(
|
||||||
|
@"(?<date>\d{1,2}\.\d{2}\.\d{4})\s+(?<time>\d{1,2}:\d{2})",
|
||||||
|
RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
public static bool TryParse(
|
||||||
|
string text,
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
out RescheduleVotingInput input,
|
||||||
|
out string error)
|
||||||
|
{
|
||||||
|
input = new RescheduleVotingInput([], default);
|
||||||
|
error = string.Empty;
|
||||||
|
|
||||||
|
var options = new List<DateTimeOffset>();
|
||||||
|
DateTimeOffset? deadline = null;
|
||||||
|
|
||||||
|
foreach (var rawLine in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
var match = DateTimePattern.Match(line);
|
||||||
|
if (!match.Success)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var value = $"{match.Groups["date"].Value} {match.Groups["time"].Value}";
|
||||||
|
if (!MoscowTime.TryParseMoscow(value, out var parsed))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (IsDeadlineLine(line))
|
||||||
|
{
|
||||||
|
deadline = parsed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
options.Add(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Count is < 2 or > 3)
|
||||||
|
{
|
||||||
|
error = "Укажите от 2 до 3 вариантов времени.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Distinct().Count() != options.Count)
|
||||||
|
{
|
||||||
|
error = "Варианты времени не должны повторяться.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadline is null)
|
||||||
|
{
|
||||||
|
error = "Укажите дедлайн голосования строкой «Дедлайн: ДД.ММ.ГГГГ ЧЧ:ММ».";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Any(option => option <= nowUtc))
|
||||||
|
{
|
||||||
|
error = "Все варианты времени должны быть в будущем.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadline.Value <= nowUtc)
|
||||||
|
{
|
||||||
|
error = "Дедлайн голосования должен быть в будущем.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadline.Value >= options.Min())
|
||||||
|
{
|
||||||
|
error = "Дедлайн голосования должен быть раньше первого предложенного времени.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
input = new RescheduleVotingInput(options, deadline.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDeadlineLine(string line)
|
||||||
|
{
|
||||||
|
var normalized = line.TrimStart('-', '*', ' ', '\t').ToLowerInvariant();
|
||||||
|
|
||||||
|
return normalized.StartsWith("дедлайн", StringComparison.Ordinal)
|
||||||
|
|| normalized.StartsWith("deadline", StringComparison.Ordinal)
|
||||||
|
|| normalized.StartsWith("до:", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RescheduleOptionDto(
|
||||||
|
Guid OptionId,
|
||||||
|
int DisplayOrder,
|
||||||
|
DateTimeOffset ProposedAt);
|
||||||
|
|
||||||
|
internal sealed record RescheduleOptionVoteDto(
|
||||||
|
Guid OptionId,
|
||||||
|
Guid PlayerId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername);
|
||||||
|
|
||||||
|
internal sealed record RescheduleOptionVoteCount(
|
||||||
|
Guid OptionId,
|
||||||
|
int VoteCount);
|
||||||
@@ -139,15 +139,10 @@ public sealed class UpdateRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
|
if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
|
||||||
{
|
{
|
||||||
var vote = parts[1]; // "yes" or "no"
|
|
||||||
if (vote is not ("yes" or "no"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var command = new HandleRescheduleVoteCommand(
|
var command = new HandleRescheduleVoteCommand(
|
||||||
ProposalId: proposalId,
|
OptionId: optionId,
|
||||||
Vote: vote,
|
|
||||||
TelegramUserId: query.From.Id,
|
TelegramUserId: query.From.Id,
|
||||||
CallbackQueryId: query.Id,
|
CallbackQueryId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
ChatId: message.Chat.Id,
|
||||||
@@ -225,6 +220,7 @@ public sealed class UpdateRouter(
|
|||||||
|
|
||||||
/listsessions — список предстоящих сессий
|
/listsessions — список предстоящих сессий
|
||||||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||||
|
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
|
||||||
/help — эта справка
|
/help — эта справка
|
||||||
""",
|
""",
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Multi-option reschedule voting with a deadline.
|
||||||
|
|
||||||
|
ALTER TABLE reschedule_proposals
|
||||||
|
ADD COLUMN voting_deadline_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN selected_option_id UUID;
|
||||||
|
|
||||||
|
CREATE TABLE reschedule_options (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
|
||||||
|
proposed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL CHECK (display_order BETWEEN 1 AND 3),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (proposal_id, id),
|
||||||
|
UNIQUE (proposal_id, display_order),
|
||||||
|
UNIQUE (proposal_id, proposed_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE reschedule_option_votes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
|
||||||
|
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
option_id UUID NOT NULL,
|
||||||
|
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (proposal_id, player_id),
|
||||||
|
FOREIGN KEY (proposal_id, option_id)
|
||||||
|
REFERENCES reschedule_options(proposal_id, id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_reschedule_voting_deadline
|
||||||
|
ON reschedule_proposals (voting_deadline_at)
|
||||||
|
WHERE status = 'Voting';
|
||||||
|
|
||||||
|
CREATE INDEX ix_reschedule_option_votes_option
|
||||||
|
ON reschedule_option_votes (option_id);
|
||||||
@@ -75,6 +75,7 @@ builder.Services.AddHostedService<TelegramBotService>();
|
|||||||
|
|
||||||
// ── Session scheduler ────────────────────────────────────────────────
|
// ── Session scheduler ────────────────────────────────────────────────
|
||||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||||
|
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,57 @@
|
|||||||
<h2>📅 Предстоящие игры</h2>
|
<h2>📅 Предстоящие игры</h2>
|
||||||
</div>
|
</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))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||||
@@ -212,12 +263,16 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid GroupId { get; set; }
|
[Parameter] public Guid GroupId { get; set; }
|
||||||
private List<WebSession>? sessions;
|
private List<WebSession>? sessions;
|
||||||
|
private WebGroupManagement? groupManagement;
|
||||||
private List<BatchBulkEditModel> batchModels = [];
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
|
private long? removingCoGmId;
|
||||||
|
private bool isAddingCoGm;
|
||||||
private long telegramId;
|
private long telegramId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
|
private CoGmEditModel coGmModel = new();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -233,6 +288,13 @@
|
|||||||
|
|
||||||
private async Task 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);
|
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||||
if (sessions is null)
|
if (sessions is null)
|
||||||
{
|
{
|
||||||
@@ -243,6 +305,72 @@
|
|||||||
RebuildBatchModels();
|
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)
|
private async Task PromoteWaitlisted(Guid sessionId)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
@@ -404,6 +532,22 @@
|
|||||||
|
|
||||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
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)
|
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||||
{
|
{
|
||||||
if (orderedSessions.Count < 2)
|
if (orderedSessions.Count < 2)
|
||||||
@@ -466,4 +610,11 @@
|
|||||||
public int SessionCount { get; init; }
|
public int SessionCount { get; init; }
|
||||||
public string CloneInterval { get; set; } = "week";
|
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 "/"
|
@page "/"
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using GmRelay.Shared.Domain
|
||||||
@using GmRelay.Web.Services
|
@using GmRelay.Web.Services
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject AuthorizedSessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
<div class="group-card-icon">🎮</div>
|
<div class="group-card-icon">🎮</div>
|
||||||
<h3 class="group-card-title">@group.Name</h3>
|
<h3 class="group-card-title">@group.Name</h3>
|
||||||
<p class="group-card-id">ID: @group.TelegramChatId</p>
|
<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 href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||||
Посмотреть игры →
|
Посмотреть игры →
|
||||||
</a>
|
</a>
|
||||||
@@ -97,4 +101,7 @@
|
|||||||
|
|
||||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatRole(string role) =>
|
||||||
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||||
sessionStore.GetGroupsForGmAsync(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)
|
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
|
||||||
{
|
{
|
||||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||||
@@ -110,9 +128,45 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
|
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)
|
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||||
{
|
{
|
||||||
var group = await sessionStore.GetGroupAsync(groupId);
|
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
|
||||||
return group?.GmTelegramId == gmId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ public interface ISessionStore
|
|||||||
{
|
{
|
||||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
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<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||||
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
|
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
|
||||||
@@ -15,4 +18,6 @@ public interface ISessionStore
|
|||||||
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
|
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
|
||||||
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
|
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
|
||||||
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
|
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;
|
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(
|
public sealed record WebSession(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid GroupId,
|
Guid GroupId,
|
||||||
@@ -56,7 +73,18 @@ public sealed class SessionService(
|
|||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return (await conn.QueryAsync<WebGameGroup>(
|
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();
|
new { GmId = gmId })).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +92,145 @@ public sealed class SessionService(
|
|||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
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)
|
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GM-Relay Design System v1.5.0
|
GM-Relay Design System v1.7.0
|
||||||
Dark RPG Dashboard Theme
|
Dark RPG Dashboard Theme
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
|||||||
+98
-11
@@ -5,28 +5,115 @@ namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
|||||||
public sealed class HandleRescheduleTimeInputHandlerTests
|
public sealed class HandleRescheduleTimeInputHandlerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants()
|
public void TryParseVotingInput_ShouldAcceptTwoOptionsAndDeadline()
|
||||||
{
|
{
|
||||||
var approvedId = Guid.NewGuid();
|
var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
|
||||||
var pendingId = Guid.NewGuid();
|
|
||||||
|
var ok = RescheduleVotingInput.TryParse(
|
||||||
|
"""
|
||||||
|
25.04.2026 19:30
|
||||||
|
26.04.2026 18:00
|
||||||
|
Дедлайн: 25.04.2026 12:00
|
||||||
|
""",
|
||||||
|
now,
|
||||||
|
out var input,
|
||||||
|
out var error);
|
||||||
|
|
||||||
|
Assert.True(ok, error);
|
||||||
|
Assert.Equal(2, input.Options.Count);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 4, 25, 16, 30, 0, TimeSpan.Zero), input.Options[0]);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 4, 26, 15, 0, 0, TimeSpan.Zero), input.Options[1]);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 4, 25, 9, 0, 0, TimeSpan.Zero), input.Deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseVotingInput_ShouldRejectSingleOption()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var ok = RescheduleVotingInput.TryParse(
|
||||||
|
"""
|
||||||
|
25.04.2026 19:30
|
||||||
|
Дедлайн: 25.04.2026 12:00
|
||||||
|
""",
|
||||||
|
now,
|
||||||
|
out _,
|
||||||
|
out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Equal("Укажите от 2 до 3 вариантов времени.", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildVotingMessage_ShouldShowOptionsDeadlineVotesAndPendingParticipants()
|
||||||
|
{
|
||||||
|
var firstOptionId = Guid.NewGuid();
|
||||||
|
var secondOptionId = Guid.NewGuid();
|
||||||
|
var aliceId = Guid.NewGuid();
|
||||||
|
var bobId = Guid.NewGuid();
|
||||||
|
var charlieId = Guid.NewGuid();
|
||||||
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
|
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
|
||||||
var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero);
|
var deadline = new DateTimeOffset(2026, 4, 25, 9, 0, 0, TimeSpan.Zero);
|
||||||
|
var options = new List<RescheduleOptionDto>
|
||||||
|
{
|
||||||
|
new(firstOptionId, 1, new DateTimeOffset(2026, 4, 26, 16, 0, 0, TimeSpan.Zero)),
|
||||||
|
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
|
||||||
|
};
|
||||||
var participants = new List<VoteParticipantDto>
|
var participants = new List<VoteParticipantDto>
|
||||||
{
|
{
|
||||||
new(approvedId, "Alice", "alice"),
|
new(aliceId, "Alice", "alice"),
|
||||||
new(pendingId, "Bob", null)
|
new(bobId, "Bob", null),
|
||||||
|
new(charlieId, "Charlie", null)
|
||||||
|
};
|
||||||
|
var votes = new List<RescheduleOptionVoteDto>
|
||||||
|
{
|
||||||
|
new(firstOptionId, aliceId, "Alice", "alice"),
|
||||||
|
new(secondOptionId, bobId, "Bob", null)
|
||||||
};
|
};
|
||||||
|
|
||||||
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
"Shadowrun",
|
"Shadowrun",
|
||||||
currentTime,
|
currentTime,
|
||||||
newTime,
|
deadline,
|
||||||
|
options,
|
||||||
participants,
|
participants,
|
||||||
[approvedId]);
|
votes);
|
||||||
|
|
||||||
Assert.Contains("Shadowrun", text);
|
Assert.Contains("Shadowrun", text);
|
||||||
Assert.Contains("✅ @alice", text);
|
Assert.Contains("Дедлайн: <b>25 апреля 2026, 12:00</b> (МСК)", text);
|
||||||
Assert.Contains("⏳ Bob", text);
|
Assert.Contains("1. <b>26 апреля 2026, 19:00</b> (МСК) — 1 голос", text);
|
||||||
Assert.Contains("Голоса: 1/2 ✅", text);
|
Assert.Contains("@alice", text);
|
||||||
|
Assert.Contains("2. <b>27 апреля 2026, 20:00</b> (МСК) — 1 голос", text);
|
||||||
|
Assert.Contains("Bob", text);
|
||||||
|
Assert.Contains("Не проголосовали: Charlie", text);
|
||||||
|
Assert.Contains("Голосов: 2/3", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildVotingKeyboard_ShouldCreateOneButtonPerOption()
|
||||||
|
{
|
||||||
|
var firstOptionId = Guid.NewGuid();
|
||||||
|
var secondOptionId = Guid.NewGuid();
|
||||||
|
var options = new List<RescheduleOptionDto>
|
||||||
|
{
|
||||||
|
new(firstOptionId, 1, new DateTimeOffset(2026, 4, 26, 16, 0, 0, TimeSpan.Zero)),
|
||||||
|
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
|
||||||
|
};
|
||||||
|
|
||||||
|
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
||||||
|
var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
|
||||||
|
Assert.Collection(
|
||||||
|
buttons,
|
||||||
|
button =>
|
||||||
|
{
|
||||||
|
Assert.Equal("1. 26.04 19:00", button.Text);
|
||||||
|
Assert.Equal($"reschedule_vote:{firstOptionId}", button.CallbackData);
|
||||||
|
},
|
||||||
|
button =>
|
||||||
|
{
|
||||||
|
Assert.Equal("2. 27.04 20:00", button.Text);
|
||||||
|
Assert.Equal($"reschedule_vote:{secondOptionId}", button.CallbackData);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-18
@@ -5,32 +5,50 @@ namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
|||||||
public sealed class RescheduleVoteRulesTests
|
public sealed class RescheduleVoteRulesTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_ShouldReject_WhenParticipantVotesNo()
|
public void SelectWinner_ShouldApproveSingleTopOption()
|
||||||
{
|
{
|
||||||
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3);
|
var winningOptionId = Guid.NewGuid();
|
||||||
|
var otherOptionId = Guid.NewGuid();
|
||||||
|
|
||||||
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
|
var decision = RescheduleVoteRules.SelectWinner(
|
||||||
Assert.False(decision.ShouldRescheduleSession);
|
[
|
||||||
Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText);
|
new RescheduleOptionVoteCount(winningOptionId, 3),
|
||||||
}
|
new RescheduleOptionVoteCount(otherOptionId, 1)
|
||||||
|
]);
|
||||||
[Fact]
|
|
||||||
public void Evaluate_ShouldApprove_WhenEveryoneVotedYes()
|
|
||||||
{
|
|
||||||
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3);
|
|
||||||
|
|
||||||
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
|
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
|
||||||
Assert.True(decision.ShouldRescheduleSession);
|
Assert.Equal(winningOptionId, decision.SelectedOptionId);
|
||||||
Assert.True(decision.ShouldResetParticipantRsvps);
|
Assert.Equal("Победил вариант с большинством голосов.", decision.Reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_ShouldStayPending_WhileVotesOutstanding()
|
public void SelectWinner_ShouldRejectTie()
|
||||||
{
|
{
|
||||||
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2);
|
var firstOptionId = Guid.NewGuid();
|
||||||
|
var secondOptionId = Guid.NewGuid();
|
||||||
|
|
||||||
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome);
|
var decision = RescheduleVoteRules.SelectWinner(
|
||||||
Assert.False(decision.ShouldRescheduleSession);
|
[
|
||||||
Assert.False(decision.ShouldResetParticipantRsvps);
|
new RescheduleOptionVoteCount(firstOptionId, 2),
|
||||||
|
new RescheduleOptionVoteCount(secondOptionId, 2)
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
|
||||||
|
Assert.Null(decision.SelectedOptionId);
|
||||||
|
Assert.Equal("Голоса разделились поровну, перенос не применяется.", decision.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectWinner_ShouldRejectWhenNobodyVoted()
|
||||||
|
{
|
||||||
|
var decision = RescheduleVoteRules.SelectWinner(
|
||||||
|
[
|
||||||
|
new RescheduleOptionVoteCount(Guid.NewGuid(), 0),
|
||||||
|
new RescheduleOptionVoteCount(Guid.NewGuid(), 0)
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
|
||||||
|
Assert.Null(decision.SelectedOptionId);
|
||||||
|
Assert.Equal("Никто не проголосовал до дедлайна, перенос не применяется.", decision.Reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,34 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Equal("Session A", sessions[0].Title);
|
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]
|
[Fact]
|
||||||
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
|
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
|
||||||
{
|
{
|
||||||
@@ -211,6 +239,105 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink);
|
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]
|
[Fact]
|
||||||
public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
|
public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
|
||||||
{
|
{
|
||||||
@@ -335,10 +462,12 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
|
|
||||||
private sealed class FakeSessionStore(
|
private sealed class FakeSessionStore(
|
||||||
IEnumerable<WebGameGroup>? groups = null,
|
IEnumerable<WebGameGroup>? groups = null,
|
||||||
IEnumerable<WebSession>? sessions = null) : ISessionStore
|
IEnumerable<WebSession>? sessions = null,
|
||||||
|
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
|
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
|
||||||
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.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 UpdateCalled { get; private set; }
|
||||||
public bool PromoteCalled { get; private set; }
|
public bool PromoteCalled { get; private set; }
|
||||||
@@ -346,6 +475,8 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public bool UpdateBatchNotificationModeCalled { get; private set; }
|
public bool UpdateBatchNotificationModeCalled { get; private set; }
|
||||||
public bool RescheduleBatchCalled { get; private set; }
|
public bool RescheduleBatchCalled { get; private set; }
|
||||||
public bool CloneBatchCalled { 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? LastUpdatedSessionId { get; private set; }
|
||||||
public Guid? LastUpdatedGroupId { get; private set; }
|
public Guid? LastUpdatedGroupId { get; private set; }
|
||||||
public string? LastUpdatedTitle { get; private set; }
|
public string? LastUpdatedTitle { get; private set; }
|
||||||
@@ -368,9 +499,15 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public Guid? LastClonedBatchId { get; private set; }
|
public Guid? LastClonedBatchId { get; private set; }
|
||||||
public Guid? LastClonedBatchGroupId { get; private set; }
|
public Guid? LastClonedBatchGroupId { get; private set; }
|
||||||
public BatchCloneInterval? LastCloneInterval { 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) =>
|
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)
|
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||||
{
|
{
|
||||||
@@ -378,6 +515,36 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.FromResult(group);
|
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) =>
|
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
|
||||||
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
|
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
|
||||||
|
|
||||||
@@ -474,5 +641,32 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
DateTime.UtcNow.AddDays(7),
|
DateTime.UtcNow.AddDays(7),
|
||||||
1));
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user