diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs new file mode 100644 index 0000000..5a83a4c --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -0,0 +1,84 @@ +using Dapper; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Rendering; +using Npgsql; + +namespace GmRelay.DiscordBot.Features.Sessions; + +public sealed record DiscordDeleteSessionResult( + string ReplyText, + SessionBatchViewModel? UpdatedView, + string? EmptyMessage = null); + +public sealed class DiscordDeleteSessionHandler( + NpgsqlDataSource dataSource, + DiscordPermissionChecker permissionChecker, + DiscordListSessionsHandler listSessionsHandler, + ILogger logger) +{ + public async Task HandleAsync( + string guildId, + string channelId, + ulong userId, + ulong resolvedPermissions, + ulong guildOwnerId, + Guid sessionId, + CancellationToken cancellationToken) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var dbManagerUserIds = await connection.QueryAsync( + @"SELECT CAST(p.external_user_id AS BIGINT) + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + JOIN game_groups g ON g.id = gm.group_id + WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", + new { GuildId = guildId }); + + if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) + { + return new DiscordDeleteSessionResult( + "Только owner, администратор или manager могут удалять сессии.", + UpdatedView: null); + } + + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var deletedRows = await connection.ExecuteAsync( + """ + DELETE FROM sessions s + USING game_groups g + WHERE s.group_id = g.id + AND s.id = @SessionId + AND g.platform = 'Discord' + AND g.external_group_id = @GuildId + """, + new { SessionId = sessionId, GuildId = guildId }, + transaction); + + await transaction.CommitAsync(cancellationToken); + + if (deletedRows == 0) + { + return new DiscordDeleteSessionResult( + "Сессия не найдена или уже удалена.", + UpdatedView: null); + } + + logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId); + + var updatedView = await listSessionsHandler.BuildScheduleAsync( + guildId, + channelId, + userId, + resolvedPermissions, + guildOwnerId, + cancellationToken); + + return updatedView is null + ? new DiscordDeleteSessionResult( + "Сессия удалена.", + UpdatedView: null, + EmptyMessage: "В этом сервере нет предстоящих игр.") + : new DiscordDeleteSessionResult("Сессия удалена.", updatedView); + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs index e04a1cb..6990dca 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs @@ -1,3 +1,4 @@ +using NetCord; using NetCord.Rest; using NetCord.Services.ApplicationCommands; @@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule BuildScheduleAsync( + string guildId, + string channelId, + CancellationToken cancellationToken) => + BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken); + public async Task BuildScheduleAsync( string guildId, string channelId, + ulong userId, + ulong resolvedPermissions, + ulong guildOwnerId, CancellationToken cancellationToken) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); @@ -44,6 +56,20 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource) if (sessionList.Count == 0) return null; + var dbManagerUserIds = await connection.QueryAsync( + @"SELECT CAST(p.external_user_id AS BIGINT) + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + JOIN game_groups g ON g.id = gm.group_id + WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", + new { GuildId = guildId }); + + var canManage = permissionChecker.CanManageSchedule( + guildOwnerId, + userId, + dbManagerUserIds, + resolvedPermissions); + var sessionIds = sessionList.Select(s => s.Id).ToList(); var participants = await connection.QueryAsync( @"SELECT sp.session_id as SessionId, @@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource) var batchDtos = sessionList.Select(s => new SessionBatchDto( s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList(); - return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList()); + var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList()); + return canManage ? AddManagerActions(view) : view; } + + internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) => + view with + { + Sessions = view.Sessions + .Select(session => + { + if (SessionStatus.IsCancelled(session.Status)) + return session; + + var actions = session.AvailableActions + .Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)]) + .ToList(); + + return session with { AvailableActions = actions }; + }) + .ToList() + }; } diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs index 2156abf..286e3b5 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs @@ -1,8 +1,10 @@ using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Platform; +using System.Collections; using System.Globalization; using NetCord; using NetCord.Rest; @@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule( JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, HandleRsvpHandler rsvpHandler, + DiscordDeleteSessionHandler deleteSessionHandler, DiscordRescheduleVoteHandler voteHandler, DiscordInteractionReplyCache interactionReplies, ILogger logger) : ComponentInteractionModule @@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule( } var input = CreateInput(parsedSessionId); - await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + await RespondAsync(InteractionCallback.DeferredModifyMessage); + SessionInteractionResult result; try { - await joinSessionHandler.HandleAsync( - DiscordSessionInteractionMapper.CreateJoinCommand(input), + result = await joinSessionHandler.HandleAsync( + DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true }, CancellationToken.None); } catch (Exception ex) { logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId); - await CompleteResponseAsync("Не удалось обработать кнопку."); + await FollowupEphemeralAsync("Не удалось обработать кнопку."); return; } - await CompleteWithStoredReplyAsync(input.InteractionId); + await CompleteScheduleUpdateResponseAsync(input.InteractionId, result); } [ComponentInteraction("leave_session")] @@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule( } var input = CreateInput(parsedSessionId); - await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + await RespondAsync(InteractionCallback.DeferredModifyMessage); + SessionInteractionResult result; try { - await leaveSessionHandler.HandleAsync( - DiscordSessionInteractionMapper.CreateLeaveCommand(input), + result = await leaveSessionHandler.HandleAsync( + DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true }, CancellationToken.None); } catch (Exception ex) { logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId); - await CompleteResponseAsync("Не удалось обработать кнопку."); + await FollowupEphemeralAsync("Не удалось обработать кнопку."); return; } - await CompleteWithStoredReplyAsync(input.InteractionId); + await CompleteScheduleUpdateResponseAsync(input.InteractionId, result); + } + + [ComponentInteraction("delete_session")] + public async Task DeleteAsync(string sessionId) + { + if (!Guid.TryParse(sessionId, out var parsedSessionId)) + { + await RespondAsync(CreateEphemeralReply("Session button is outdated.")); + return; + } + + var input = CreateInput(parsedSessionId); + var member = Context.User as GuildInteractionUser; + var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions; + + await RespondAsync(InteractionCallback.DeferredModifyMessage); + try + { + var result = await deleteSessionHandler.HandleAsync( + guildId: input.GuildId, + channelId: input.ChannelId, + userId: input.UserId, + resolvedPermissions: resolvedPermissions, + guildOwnerId: 0, + sessionId: parsedSessionId, + CancellationToken.None); + + await CompleteDeleteResponseAsync(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId); + await FollowupEphemeralAsync("Не удалось удалить сессию."); + } } [ComponentInteraction("rsvp")] @@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule( catch (Exception ex) { logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId); - await CompleteResponseAsync("РќРµ удалось обработать РєРЅРѕРїРєСѓ."); + await CompleteResponseAsync("Не удалось обработать кнопку."); return; } @@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule( await CompleteResponseAsync(reply?.Text ?? "Session updated."); } + private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result) + { + var updatedView = result.UpdatedView; + if (updatedView is not null && SourceMessageHasDeleteAction()) + { + updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView); + } + + if (updatedView is not null) + { + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView); + await ModifyResponseAsync(options => + { + options.Embeds = embeds; + options.Components = actionRows; + }); + } + + var reply = interactionReplies.Take(interactionId); + await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText); + } + + private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result) + { + if (result.UpdatedView is not null) + { + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView); + await ModifyResponseAsync(options => + { + options.Embeds = embeds; + options.Components = actionRows; + }); + } + else if (result.EmptyMessage is not null) + { + await ModifyResponseAsync(options => + { + options.Content = result.EmptyMessage; + options.Embeds = []; + options.Components = []; + }); + } + + await FollowupEphemeralAsync(result.ReplyText); + } + private Task CompleteResponseAsync(string text) => ModifyResponseAsync(options => options.Content = text); + private Task FollowupEphemeralAsync(string text) => + FollowupAsync(new InteractionMessageProperties() + .WithContent(text) + .WithFlags(MessageFlags.Ephemeral)); + + private bool SourceMessageHasDeleteAction() => + Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true; + + private static bool ComponentContainsDeleteAction(object? component) + { + if (component is null) + return false; + + if (component is IInteractiveComponent interactive + && interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal)) + return true; + + var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable; + if (nestedComponents is null) + return false; + + foreach (var nestedComponent in nestedComponents) + { + if (ComponentContainsDeleteAction(nestedComponent)) + return true; + } + + return false; + } + private static InteractionCallbackProperties CreateEphemeralReply(string text) => InteractionCallback.Message( new InteractionMessageProperties() diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index bf8f8e5..a9fcf68 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -56,6 +56,7 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs index 0486b11..1724028 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -13,7 +13,12 @@ public sealed record JoinSessionCommand( PlatformUser User, string InteractionId, PlatformGroup Group, - PlatformMessageRef ScheduleMessage); + PlatformMessageRef ScheduleMessage, + bool DeferScheduleUpdate = false); + +public sealed record SessionInteractionResult( + string ReplyText, + SessionBatchViewModel? UpdatedView = null); // DTOs for AOT compilation internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers); @@ -24,7 +29,7 @@ public sealed class JoinSessionHandler( IScheduleMessageUpdateLock scheduleUpdateLock, ILogger logger) { - public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) + public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) { await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct); await using var connection = await dataSource.OpenConnectionAsync(ct); @@ -77,15 +82,13 @@ public sealed class JoinSessionHandler( if (batchInfo is null) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); - return; + return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); } if (SessionStatus.IsCancelled(batchInfo.Status)) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); - return; + return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); } var existingRegistrationStatus = await connection.ExecuteScalarAsync( @@ -105,8 +108,7 @@ public sealed class JoinSessionHandler( var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Вы уже в листе ожидания!" : "Вы уже записаны!"; - await AnswerAsync(command.InteractionId, alreadyText, ct); - return; + return await AnswerAsync(command.InteractionId, alreadyText, ct); } var activeParticipants = await connection.ExecuteScalarAsync( @@ -139,8 +141,7 @@ public sealed class JoinSessionHandler( if (inserted == 0) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct); - return; + return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct); } // Загружаем весь батч для перерисовки @@ -168,17 +169,20 @@ public sealed class JoinSessionHandler( // 4. Перерисовываем сообщение var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); - await messenger.UpdateScheduleAsync( - new PlatformScheduleMessage( - command.Group, - view, - command.ScheduleMessage), - ct); + if (!command.DeferScheduleUpdate) + { + await messenger.UpdateScheduleAsync( + new PlatformScheduleMessage( + command.Group, + view, + command.ScheduleMessage), + ct); + } var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Основной состав заполнен. Вы добавлены в лист ожидания." : "Вы успешно записаны!"; - await AnswerAsync(command.InteractionId, callbackText, ct); + return await AnswerAsync(command.InteractionId, callbackText, ct, view); } catch (Exception ex) { @@ -191,10 +195,17 @@ public sealed class JoinSessionHandler( var errorText = transactionCommitted ? "Регистрация сохранена, но не удалось обновить сообщение расписания." : "Произошла ошибка при регистрации."; - await AnswerAsync(command.InteractionId, errorText, ct); + return await AnswerAsync(command.InteractionId, errorText, ct); } } - private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => - messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); + private async Task AnswerAsync( + string interactionId, + string text, + CancellationToken ct, + SessionBatchViewModel? updatedView = null) + { + await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); + return new SessionInteractionResult(text, updatedView); + } } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs index d58325b..86cdf8e 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand( PlatformUser User, string InteractionId, PlatformGroup Group, - PlatformMessageRef ScheduleMessage); + PlatformMessageRef ScheduleMessage, + bool DeferScheduleUpdate = false); internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); @@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler( IScheduleMessageUpdateLock scheduleUpdateLock, ILogger logger) { - public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) + public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) { await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct); await using var connection = await dataSource.OpenConnectionAsync(ct); @@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler( if (session is null) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); - return; + return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); } if (SessionStatus.IsCancelled(session.Status)) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); - return; + return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); } var platform = command.User.Platform.ToString(); @@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler( if (participant is null) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct); - return; + return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct); } await connection.ExecuteAsync( @@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler( transactionCommitted = true; var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); - await messenger.UpdateScheduleAsync( - new PlatformScheduleMessage( - command.Group, - view, - command.ScheduleMessage), - ct); + if (!command.DeferScheduleUpdate) + { + await messenger.UpdateScheduleAsync( + new PlatformScheduleMessage( + command.Group, + view, + command.ScheduleMessage), + ct); + } var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Вы удалены из листа ожидания." @@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler( ? "Вы отписались от сессии." : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; - await AnswerAsync(command.InteractionId, callbackText, ct); + return await AnswerAsync(command.InteractionId, callbackText, ct, view); } catch (Exception ex) { @@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler( var errorText = transactionCommitted ? "Запись снята, но не удалось обновить сообщение расписания." : "Произошла ошибка при отмене записи."; - await AnswerAsync(command.InteractionId, errorText, ct); + return await AnswerAsync(command.InteractionId, errorText, ct); } } - private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => - messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); + private async Task AnswerAsync( + string interactionId, + string text, + CancellationToken ct, + SessionBatchViewModel? updatedView = null) + { + await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); + return new SessionInteractionResult(text, updatedView); + } } diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index c99c976..212c065 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -104,24 +104,38 @@ public sealed class SessionService( public async Task> GetGroupsForUserAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); - var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); - if (effectiveId is null) + var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) return []; return (await conn.QueryAsync( """ + WITH visible_groups AS ( + SELECT gm.group_id, + CASE + WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole + ELSE @CoGmRole + END AS ManagerRole + FROM group_managers gm + WHERE gm.player_id = ANY(@PlayerIds) + GROUP BY gm.group_id + ) SELECT g.id, g.telegram_chat_id AS TelegramChatId, g.external_group_id AS ExternalGroupId, g.name, g.platform AS Platform, - gm.role AS ManagerRole - FROM group_managers gm - JOIN game_groups g ON g.id = gm.group_id - WHERE gm.player_id = @PlayerId + vg.ManagerRole + FROM visible_groups vg + JOIN game_groups g ON g.id = vg.group_id ORDER BY g.name """, - new { PlayerId = effectiveId.Value })).ToList(); + new + { + PlayerIds = playerIds, + OwnerRole = GroupManagerRoleExtensions.OwnerValue, + CoGmRole = GroupManagerRoleExtensions.CoGmValue + })).ToList(); } public async Task GetGroupAsync(Guid groupId) @@ -144,8 +158,8 @@ public sealed class SessionService( public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); - var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); - if (effectiveId is null) + var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) return false; return await conn.ExecuteScalarAsync( @@ -154,17 +168,17 @@ public sealed class SessionService( SELECT 1 FROM group_managers WHERE group_id = @GroupId - AND player_id = @PlayerId + AND player_id = ANY(@PlayerIds) ) """, - new { GroupId = groupId, PlayerId = effectiveId.Value }); + new { GroupId = groupId, PlayerIds = playerIds }); } public async Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); - var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); - if (effectiveId is null) + var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) return false; return await conn.ExecuteScalarAsync( @@ -173,11 +187,11 @@ public sealed class SessionService( SELECT 1 FROM group_managers WHERE group_id = @GroupId - AND player_id = @PlayerId + AND player_id = ANY(@PlayerIds) AND role = @OwnerRole ) """, - new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); + new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task> GetGroupManagersAsync(Guid groupId) @@ -1561,6 +1575,23 @@ public sealed class SessionService( return primaryId ?? playerId; } + private static async Task _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + return []; + + return (await conn.QueryAsync( + """ + SELECT @EffectiveId + UNION + SELECT secondary_player_id + FROM player_links + WHERE primary_player_id = @EffectiveId + """, + new { EffectiveId = effectiveId.Value })).ToArray(); + } + private static async Task _UpsertPlayerAndGetIdAsync( NpgsqlConnection conn, string platform, string externalUserId, string displayName, string? avatarUrl, NpgsqlTransaction? transaction) diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs index b1a2398..2270513 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs @@ -47,6 +47,17 @@ public sealed class DiscordListSessionsHandlerTests Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal); } + [Fact] + public void Handler_ShouldExposeDeleteActionForManagers() + { + var repoRoot = GetRepoRoot(); + var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs"); + var handler = File.ReadAllText(handlerPath); + + Assert.Contains("delete_session", handler, StringComparison.Ordinal); + Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal); + } + [Fact] public void Command_ShouldExist() { @@ -66,4 +77,18 @@ public sealed class DiscordListSessionsHandlerTests Assert.Contains("SlashCommand", command, StringComparison.Ordinal); Assert.Contains("listsessions", command, StringComparison.Ordinal); } + + [Fact] + public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild() + { + var repoRoot = GetRepoRoot(); + var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs"); + + Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist."); + + var handler = File.ReadAllText(handlerPath); + Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal); + Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal); + Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal); + } } diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs index a01b46f..d07b85c 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs @@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal); } + [Fact] + public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs"); + + Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal); + Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal); + Assert.Contains("FollowupAsync", source, StringComparison.Ordinal); + Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal); + } + + [Fact] + public async Task Module_ShouldRouteDeleteSessionButtons() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs"); + + Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal); + Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal); + } + [Fact] public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler() { diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs index 29b8b60..bebac15 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs @@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests AssertProperty("InteractionId", typeof(string)); AssertProperty("Group", typeof(PlatformGroup)); AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertProperty("DeferScheduleUpdate", typeof(bool)); AssertNoTelegramSpecificProperties(); } @@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests AssertProperty("InteractionId", typeof(string)); AssertProperty("Group", typeof(PlatformGroup)); AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertProperty("DeferScheduleUpdate", typeof(bool)); AssertNoTelegramSpecificProperties(); } + [Fact] + public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView() + { + var resultType = typeof(JoinSessionCommand).Assembly.GetType( + "GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult"); + + Assert.NotNull(resultType); + AssertProperty(resultType, "ReplyText", typeof(string)); + AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel)); + } + private static void AssertProperty(string name, Type expectedType) { - var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name); + AssertProperty(typeof(T), name, expectedType); + } + + private static void AssertProperty(Type type, string name, Type expectedType) + { + var property = Assert.Single(type.GetProperties(), property => property.Name == name); Assert.Equal(expectedType, property.PropertyType); } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs index 9a2009c..450b823 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs @@ -91,6 +91,15 @@ public sealed class PlatformIdentityMigrationTests Assert.Contains("platform", service, StringComparison.Ordinal); } + [Fact] + public async Task WebSessionService_ShouldAuthorizeGroupsAcrossLinkedIdentities() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); + + Assert.Contains("_ResolveLinkedPlayerIdsAsync", service, StringComparison.Ordinal); + Assert.Contains("player_id = ANY(@PlayerIds)", service, StringComparison.Ordinal); + } + [Fact] public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername() {