feat: support co-gm group delegation
This commit is contained in:
@@ -16,7 +16,7 @@ public sealed record CancelSessionCommand(
|
||||
int MessageId);
|
||||
|
||||
// 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(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -29,13 +29,23 @@ public sealed class CancelSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Проверяем, что запрос делает ГМ данной сессии
|
||||
// 1. Проверяем, что запрос делает управляющий данной группы.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.notification_mode AS NotificationMode,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
@@ -43,9 +53,9 @@ public sealed class CancelSessionHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient,
|
||||
@@ -74,16 +76,64 @@ public sealed class CreateSessionHandler(
|
||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||
transaction);
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId)
|
||||
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id;
|
||||
SELECT g.id AS GroupId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = g.id
|
||||
AND p.telegram_id = @GmId
|
||||
) AS CanManage
|
||||
FROM game_groups g
|
||||
WHERE g.telegram_chat_id = @ChatId
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
new { ChatId = chatId, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
Guid groupId;
|
||||
if (existingGroup is null)
|
||||
{
|
||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId)
|
||||
RETURNING id;
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE p.telegram_id = @GmId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existingGroup.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = existingGroup.GroupId;
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
|
||||
new { ChatName = chatTitle, GroupId = groupId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
int? messageThreadId = null;
|
||||
if (message.Chat.IsForum)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers);
|
||||
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers);
|
||||
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||
|
||||
public sealed class PromoteWaitlistedPlayerHandler(
|
||||
@@ -34,13 +34,18 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.max_players AS MaxPlayers,
|
||||
g.gm_telegram_id AS GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE
|
||||
""",
|
||||
new { command.SessionId },
|
||||
new { command.SessionId, command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
@@ -50,10 +55,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
|
||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Fetch session and verify GM
|
||||
// 1. Fetch session and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.thread_id AS ThreadId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
@@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,16 +87,23 @@ public sealed class DeleteSessionHandler(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
g.gm_telegram_id as GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = command.ChatId,
|
||||
command.TelegramUserId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
@@ -110,8 +127,8 @@ public sealed class DeleteSessionHandler(
|
||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
var isGm = command.TelegramUserId == sessionsList.First().GmId;
|
||||
var keyboard = isGm
|
||||
var canManage = sessionsList.First().CanManage;
|
||||
var keyboard = canManage
|
||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||
: null;
|
||||
|
||||
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId);
|
||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
|
||||
public sealed class ListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -20,16 +20,23 @@ public sealed class ListSessionsHandler(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
g.gm_telegram_id as GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = message.Chat.Id,
|
||||
TelegramUserId = message.From?.Id,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
@@ -56,8 +63,8 @@ public sealed class ListSessionsHandler(
|
||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
var isGm = message.From?.Id == sessionsList.First().GmId;
|
||||
var keyboard = isGm
|
||||
var canManage = sessionsList.First().CanManage;
|
||||
var keyboard = canManage
|
||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||
: null;
|
||||
|
||||
+7
@@ -62,6 +62,13 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
WHERE rp.proposed_by = @GmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.telegram_chat_id = @ChatId
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @GmId
|
||||
)
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record InitiateRescheduleCommand(
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler(
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Verify GM ownership
|
||||
// 1. Verify group management access.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title, g.gm_telegram_id AS GmId
|
||||
SELECT s.title AS Title,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||
""",
|
||||
new { command.SessionId, Cancelled = SessionStatus.Cancelled });
|
||||
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
@@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
</div>
|
||||
|
||||
@if (groupManagement is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Управление группой</h3>
|
||||
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
@foreach (var manager in groupManagement.Managers)
|
||||
{
|
||||
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||
@FormatManager(manager)
|
||||
</span>
|
||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
|
||||
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (groupManagement.CurrentUserIsOwner)
|
||||
{
|
||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя</label>
|
||||
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Username</label>
|
||||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||
@(isAddingCoGm ? "⏳ Добавляем..." : "➕ Добавить co-GM")
|
||||
</button>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -212,12 +263,16 @@
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
private List<WebSession>? sessions;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private long? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private long telegramId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private CoGmEditModel coGmModel = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -233,6 +288,13 @@
|
||||
|
||||
private async Task LoadSessions()
|
||||
{
|
||||
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
|
||||
if (groupManagement is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||
if (sessions is null)
|
||||
{
|
||||
@@ -243,6 +305,72 @@
|
||||
RebuildBatchModels();
|
||||
}
|
||||
|
||||
private async Task AddCoGm()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
|
||||
{
|
||||
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
|
||||
return;
|
||||
}
|
||||
|
||||
isAddingCoGm = true;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.AddCoGmForOwnerAsync(
|
||||
GroupId,
|
||||
telegramId,
|
||||
coGmModel.TelegramId.Value,
|
||||
coGmModel.DisplayName,
|
||||
coGmModel.TelegramUsername);
|
||||
|
||||
coGmModel = new();
|
||||
successMessage = "Co-GM добавлен.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isAddingCoGm = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveCoGm(long coGmTelegramId)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
removingCoGmId = coGmTelegramId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
|
||||
successMessage = "Co-GM удалён.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
removingCoGmId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PromoteWaitlisted(Guid sessionId)
|
||||
{
|
||||
errorMessage = null;
|
||||
@@ -404,6 +532,22 @@
|
||||
|
||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||||
|
||||
private string CurrentUserRole =>
|
||||
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
|
||||
?? GroupManagerRoleExtensions.CoGmValue;
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
|
||||
private static string FormatManager(WebGroupManager manager)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "@" + manager.TelegramUsername;
|
||||
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
|
||||
}
|
||||
|
||||
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||
{
|
||||
if (orderedSessions.Count < 2)
|
||||
@@ -466,4 +610,11 @@
|
||||
public int SessionCount { get; init; }
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
private sealed class CoGmEditModel
|
||||
{
|
||||
public long? TelegramId { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string? TelegramUsername { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@@ -43,6 +44,9 @@
|
||||
<div class="group-card-icon">🎮</div>
|
||||
<h3 class="group-card-title">@group.Name</h3>
|
||||
<p class="group-card-id">ID: @group.TelegramChatId</p>
|
||||
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
|
||||
@FormatRole(group.ManagerRole)
|
||||
</span>
|
||||
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||
Посмотреть игры →
|
||||
</a>
|
||||
@@ -97,4 +101,7 @@
|
||||
|
||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||
}
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||
sessionStore.GetGroupsForGmAsync(gmId);
|
||||
|
||||
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var group = await sessionStore.GetGroupAsync(groupId);
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var managers = await sessionStore.GetGroupManagersAsync(groupId);
|
||||
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
|
||||
return new WebGroupManagement(group, managers, isOwner);
|
||||
}
|
||||
|
||||
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||
@@ -110,9 +128,45 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
|
||||
}
|
||||
|
||||
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
|
||||
{
|
||||
if (coGmTelegramId <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
|
||||
}
|
||||
|
||||
if (ownerTelegramId == coGmTelegramId)
|
||||
{
|
||||
throw new InvalidOperationException("Owner is already a group manager.");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
|
||||
}
|
||||
|
||||
var normalizedName = string.IsNullOrWhiteSpace(displayName)
|
||||
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
|
||||
: displayName.Trim();
|
||||
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
|
||||
? null
|
||||
: telegramUsername.Trim().TrimStart('@');
|
||||
|
||||
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
|
||||
}
|
||||
|
||||
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
|
||||
{
|
||||
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
|
||||
}
|
||||
|
||||
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
|
||||
}
|
||||
|
||||
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
var group = await sessionStore.GetGroupAsync(groupId);
|
||||
return group?.GmTelegramId == gmId;
|
||||
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
|
||||
@@ -15,4 +18,6 @@ public interface ISessionStore
|
||||
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
|
||||
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
|
||||
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
|
||||
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
|
||||
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,24 @@ using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId);
|
||||
public sealed record WebGameGroup(
|
||||
Guid Id,
|
||||
long TelegramChatId,
|
||||
string Name,
|
||||
long GmTelegramId,
|
||||
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
|
||||
|
||||
public sealed record WebGroupManager(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string Role,
|
||||
DateTime AddedAt);
|
||||
|
||||
public sealed record WebGroupManagement(
|
||||
WebGameGroup Group,
|
||||
IReadOnlyList<WebGroupManager> Managers,
|
||||
bool CurrentUserIsOwner);
|
||||
public sealed record WebSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
@@ -56,7 +73,18 @@ public sealed class SessionService(
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebGameGroup>(
|
||||
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId",
|
||||
"""
|
||||
SELECT g.id,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.name,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
gm.role AS ManagerRole
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE p.telegram_id = @GmId
|
||||
ORDER BY g.name
|
||||
""",
|
||||
new { GmId = gmId })).ToList();
|
||||
}
|
||||
|
||||
@@ -64,8 +92,145 @@ public sealed class SessionService(
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||||
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
|
||||
new { GroupId = groupId });
|
||||
"""
|
||||
SELECT g.id,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.name,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
@OwnerRole AS ManagerRole
|
||||
FROM game_groups g
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
AND p.telegram_id = @TelegramId
|
||||
)
|
||||
""",
|
||||
new { GroupId = groupId, TelegramId = telegramId });
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
AND p.telegram_id = @TelegramId
|
||||
AND gm.role = @OwnerRole
|
||||
)
|
||||
""",
|
||||
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebGroupManager>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
gm.role AS Role,
|
||||
gm.created_at AS AddedAt
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
|
||||
gm.created_at,
|
||||
p.display_name
|
||||
""",
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
|
||||
}
|
||||
|
||||
public async Task AddGroupCoGmAsync(
|
||||
Guid groupId,
|
||||
long ownerTelegramId,
|
||||
long coGmTelegramId,
|
||||
string displayName,
|
||||
string? telegramUsername)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username
|
||||
""",
|
||||
new
|
||||
{
|
||||
TelegramId = coGmTelegramId,
|
||||
DisplayName = displayName,
|
||||
TelegramUsername = telegramUsername
|
||||
},
|
||||
transaction);
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
|
||||
SELECT @GroupId,
|
||||
co_gm.id,
|
||||
@CoGmRole,
|
||||
owner_player.id
|
||||
FROM players co_gm
|
||||
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
|
||||
WHERE co_gm.telegram_id = @CoGmTelegramId
|
||||
ON CONFLICT (group_id, player_id) DO UPDATE
|
||||
SET role = CASE
|
||||
WHEN group_managers.role = @OwnerRole THEN group_managers.role
|
||||
ELSE EXCLUDED.role
|
||||
END,
|
||||
added_by_player_id = EXCLUDED.added_by_player_id
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
OwnerTelegramId = ownerTelegramId,
|
||||
CoGmTelegramId = coGmTelegramId,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||
},
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM group_managers gm
|
||||
USING players p
|
||||
WHERE gm.player_id = p.id
|
||||
AND gm.group_id = @GroupId
|
||||
AND p.telegram_id = @CoGmTelegramId
|
||||
AND gm.role = @CoGmRole
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
CoGmTelegramId = coGmTelegramId,
|
||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ============================================
|
||||
GM-Relay Design System v1.5.0
|
||||
GM-Relay Design System v1.6.0
|
||||
Dark RPG Dashboard Theme
|
||||
============================================ */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user