diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c3ecb38..a2124f4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.7.2 + VERSION: 2.8.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index f7d29a2..3cc296b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.7.2 + 2.8.0 net10.0 preview enable diff --git a/README.md b/README.md index 7d5511b..6c4fc47 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v2.7.2`. +**Текущая версия:** `v2.8.0`. --- diff --git a/compose.yaml b/compose.yaml index 9f47220..f14a35f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0 restart: always depends_on: db: @@ -84,7 +84,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor b/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor index ce6b05d..5b634f8 100644 --- a/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor +++ b/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor @@ -185,7 +185,7 @@ private Guid selectedGroupId; private Guid? deletingTemplateId; private bool isCreatingTemplate; - private long telegramId; + private string? externalUserId; private string? errorMessage; private string? successMessage; private CampaignTemplateEditModel templateModel = new(); @@ -195,13 +195,13 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId)) { Navigation.NavigateTo("/access-denied"); return; } - groups = await SessionService.GetGroupsForGmAsync(telegramId); + groups = await SessionService.GetGroupsForCurrentUserAsync(); selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty; if (selectedGroupId != Guid.Empty) @@ -228,7 +228,7 @@ campaignTemplates = null; campaignTemplateModels = []; - var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId); + var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId); if (templates is null) { Navigation.NavigateTo("/access-denied"); @@ -260,9 +260,8 @@ try { - await SessionService.CreateCampaignTemplateForGmAsync( + await SessionService.CreateCampaignTemplateForCurrentUserAsync( selectedGroupId, - telegramId, new CreateCampaignTemplateRequest( templateModel.Name, templateModel.Title, @@ -298,7 +297,7 @@ try { - await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId); + await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id); successMessage = "Шаблон кампании удалён."; await LoadTemplates(); } diff --git a/src/GmRelay.Web/Components/Pages/EditSession.razor b/src/GmRelay.Web/Components/Pages/EditSession.razor index af68256..2ac809d 100644 --- a/src/GmRelay.Web/Components/Pages/EditSession.razor +++ b/src/GmRelay.Web/Components/Pages/EditSession.razor @@ -87,13 +87,13 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/access-denied"); return; } - session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); + session = await SessionService.GetSessionForCurrentUserAsync(SessionId); if (session is null) { Navigation.NavigateTo("/access-denied"); @@ -114,7 +114,7 @@ try { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/access-denied"); return; @@ -122,7 +122,7 @@ var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; - await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers); + await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers); Navigation.NavigateTo($"/group/{session!.GroupId}"); } catch (SessionAccessDeniedException) diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 5f5a62c..6dbb686 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -40,8 +40,8 @@ @if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue) { - } } @@ -403,9 +403,9 @@ private Guid? promotingSessionId; private Guid? processingBatchId; private Guid? processingTemplateId; - private long? removingCoGmId; + private string? removingCoGmId; private bool isAddingCoGm; - private long telegramId; + private string? externalUserId; private string? errorMessage; private string? successMessage; private CoGmEditModel coGmModel = new(); @@ -417,7 +417,7 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId)) { Navigation.NavigateTo("/access-denied"); return; @@ -428,21 +428,21 @@ private async Task LoadSessions() { - groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId); + groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId); if (groupManagement is null) { Navigation.NavigateTo("/access-denied"); return; } - sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); + sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId); if (sessions is null) { Navigation.NavigateTo("/access-denied"); return; } - campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId); + campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId); if (campaignTemplates is null) { Navigation.NavigateTo("/access-denied"); @@ -470,8 +470,8 @@ { await SessionService.AddCoGmForOwnerAsync( GroupId, - telegramId, - coGmModel.TelegramId.Value, + "Telegram", + coGmModel.TelegramId.Value.ToString(), coGmModel.DisplayName, coGmModel.TelegramUsername); @@ -493,15 +493,15 @@ } } - private async Task RemoveCoGm(long coGmTelegramId) + private async Task RemoveCoGm(string coGmExternalUserId) { errorMessage = null; successMessage = null; - removingCoGmId = coGmTelegramId; + removingCoGmId = coGmExternalUserId; try { - await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId); + await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId); successMessage = "Co-GM удалён."; await LoadSessions(); } @@ -527,7 +527,7 @@ try { - await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId); + await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId); await LoadSessions(); } catch (SessionAccessDeniedException) @@ -559,7 +559,7 @@ loadingParticipantsSessionId = sessionId; try { - var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId); + var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId); participantsCache[sessionId] = participants ?? []; } catch (Exception ex) @@ -582,7 +582,7 @@ try { - await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId); + await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId); participantsCache.Remove(sessionId); successMessage = "Игрок исключён."; await LoadSessions(); @@ -658,10 +658,9 @@ try { - await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink); - await SessionService.UpdateBatchNotificationModeForGmAsync( + await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink); + await SessionService.UpdateBatchNotificationModeForCurrentUserAsync( batch.BatchId, - telegramId, SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode)); successMessage = "Настройки batch обновлены."; await LoadSessions(); @@ -696,7 +695,7 @@ try { var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; - await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays); + await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays); successMessage = "Расписание пачки обновлено."; await LoadSessions(); } @@ -726,7 +725,7 @@ ? BatchCloneInterval.NextMonth : BatchCloneInterval.NextWeek; - var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval); + var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval); successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр."; await LoadSessions(); } @@ -759,7 +758,7 @@ return; } - var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime); + var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime); successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр."; await LoadSessions(); } @@ -836,7 +835,7 @@ private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id; private string CurrentUserRole => - groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role + groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role ?? GroupManagerRoleExtensions.CoGmValue; private static string FormatRole(string role) => diff --git a/src/GmRelay.Web/Components/Pages/GroupStats.razor b/src/GmRelay.Web/Components/Pages/GroupStats.razor index 43ea763..0ae1ac0 100644 --- a/src/GmRelay.Web/Components/Pages/GroupStats.razor +++ b/src/GmRelay.Web/Components/Pages/GroupStats.razor @@ -5,7 +5,7 @@ @using Microsoft.AspNetCore.Components.Authorization @using System.Security.Claims @attribute [Authorize] -@inject ISessionStore SessionStore +@inject AuthorizedSessionService SessionService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -85,9 +85,9 @@
@s.DisplayName - @if (!string.IsNullOrEmpty(s.TelegramUsername)) + @if (!string.IsNullOrEmpty(s.ExternalUsername)) { - @@@s.TelegramUsername + @@@s.ExternalUsername }
@@ -171,21 +171,20 @@ Navigation.NavigateTo("/login"); return; } - var telegramIdClaim = user.FindFirst("telegram_id")?.Value - ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (!long.TryParse(telegramIdClaim, out var telegramId)) + if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/login"); return; } try { - if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId)) + var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId); + if (groupManagement is null) { Navigation.NavigateTo("/access-denied"); return; } - stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new(); + stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new(); UpdateSortedStats(); } catch (Exception ex) diff --git a/src/GmRelay.Web/Components/Pages/Home.razor b/src/GmRelay.Web/Components/Pages/Home.razor index 70ef8eb..be859dd 100644 --- a/src/GmRelay.Web/Components/Pages/Home.razor +++ b/src/GmRelay.Web/Components/Pages/Home.razor @@ -43,7 +43,7 @@
🎮

@group.Name

-

ID: @group.TelegramChatId

+

ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())

@FormatRole(group.ManagerRole) @@ -93,13 +93,13 @@ var user = authState.User; userName = user.Identity?.Name ?? "Мастер Игры"; - if (!user.TryGetTelegramId(out var telegramId)) + if (!user.TryGetPlatformIdentity(out _, out _)) { Navigation.NavigateTo("/access-denied"); return; } - groups = await SessionService.GetGroupsForGmAsync(telegramId); + groups = await SessionService.GetGroupsForCurrentUserAsync(); } private static string FormatRole(string role) => diff --git a/src/GmRelay.Web/Components/Pages/SessionHistory.razor b/src/GmRelay.Web/Components/Pages/SessionHistory.razor index ad4304c..623c133 100644 --- a/src/GmRelay.Web/Components/Pages/SessionHistory.razor +++ b/src/GmRelay.Web/Components/Pages/SessionHistory.razor @@ -56,7 +56,7 @@ { @entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC - @entry.ActorName (@entry.ActorTelegramId) + @entry.ActorName (@entry.ActorExternalUserId) @GetChangeTypeLabel(entry.ChangeType) @@ -82,13 +82,13 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/access-denied"); return; } - var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); + var session = await SessionService.GetSessionForCurrentUserAsync(SessionId); if (session is null) { Navigation.NavigateTo("/access-denied"); @@ -97,7 +97,7 @@ sessionTitle = session.Title; groupId = session.GroupId; - entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId); + entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId); } private string GetChangeTypeLabel(string changeType) => changeType switch diff --git a/src/GmRelay.Web/DiscordOAuthOptions.cs b/src/GmRelay.Web/DiscordOAuthOptions.cs index 46e0073..8ee91b3 100644 --- a/src/GmRelay.Web/DiscordOAuthOptions.cs +++ b/src/GmRelay.Web/DiscordOAuthOptions.cs @@ -2,9 +2,9 @@ namespace GmRelay.Web; public sealed class DiscordOAuthOptions { - public required string ClientId { get; set; } - public required string ClientSecret { get; set; } - public required string RedirectUri { get; set; } + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string RedirectUri { get; set; } = string.Empty; public string[] Scopes { get; set; } = ["identify", "guilds"]; public void Validate() diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 4ac6034..b3ce775 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -1,3 +1,4 @@ +using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index cf02c64..119fdc6 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -13,7 +13,16 @@ public sealed record WebGameGroup( string? ExternalGroupId, string Name, string? Platform, - string ManagerRole = GroupManagerRoleExtensions.OwnerValue); + string ManagerRole = GroupManagerRoleExtensions.OwnerValue) +{ + public long GmTelegramId { get; init; } + + public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId) + : this(id, telegramChatId, null, name, null) + { + GmTelegramId = gmTelegramId; + } +} public sealed record WebGroupManager( long TelegramId, @@ -22,7 +31,11 @@ public sealed record WebGroupManager( string? TelegramUsername, string? ExternalUsername, string Role, - DateTime AddedAt); + DateTime AddedAt) +{ + public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt) + : this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { } +} public sealed record WebGroupManagement( WebGameGroup Group, diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 0b151e8..63b8172 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:2.7.2", compose); + Assert.Contains("gmrelay-discord-bot:2.8.0", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("2.7.2", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("2.8.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v2.7.2", + "v2.8.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 1fdf051..1b1b70d 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -1,10 +1,29 @@ using GmRelay.Web.Services; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using GmRelay.Shared.Domain; namespace GmRelay.Bot.Tests.Web; public sealed class AuthorizedSessionServiceTests { + private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, externalUserId), + new Claim("TelegramId", externalUserId), + new Claim("Platform", "Telegram") + }; + if (name is not null) + claims.Add(new Claim(ClaimTypes.Name, name)); + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var httpContext = new DefaultHttpContext { User = principal }; + return new HttpContextAccessor { HttpContext = httpContext }; + } + [Fact] public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm() { @@ -19,9 +38,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId); + var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId); Assert.NotNull(sessions); Assert.Single(sessions); @@ -47,9 +67,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(coGmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId); + var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId); Assert.NotNull(sessions); Assert.Single(sessions); @@ -65,9 +86,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", 2002L) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L); + var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId); Assert.Null(sessions); } @@ -87,9 +109,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var session = await service.GetSessionForGmAsync(sessionId, gmId); + var session = await service.GetSessionForCurrentUserAsync(sessionId); Assert.NotNull(session); Assert.Equal(sessionId, session.Id); @@ -109,9 +132,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var session = await service.GetSessionForGmAsync(sessionId, 1001L); + var session = await service.GetSessionForCurrentUserAsync(sessionId); Assert.Null(session); } @@ -130,9 +154,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); + var action = () => service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); await Assert.ThrowsAsync(action); Assert.False(store.UpdateCalled); @@ -154,9 +179,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", scheduledAt, "https://example.test/b", 5); Assert.True(store.UpdateCalled); Assert.Equal(groupId, store.LastUpdatedGroupId); @@ -183,9 +209,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", originalTime, "https://example.test/a", 4); Assert.Single(store.LogEntries); Assert.Equal("Title", store.LogEntries[0].ChangeType); @@ -209,10 +236,11 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); var newTime = originalTime.AddDays(1); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", newTime, "https://example.test/b", 5); Assert.Equal(4, store.LogEntries.Count); Assert.Contains(store.LogEntries, e => e.ChangeType == "Title"); @@ -237,9 +265,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Session A", originalTime, "https://example.test/a", 4); Assert.Empty(store.LogEntries); } @@ -259,9 +288,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId); + var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId); Assert.NotNull(history); Assert.Empty(history); @@ -281,9 +311,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L); + var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId); Assert.Null(history); } @@ -303,9 +334,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId); + await service.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId); Assert.True(store.PromoteCalled); Assert.Equal(groupId, store.LastPromotedGroupId); @@ -326,9 +358,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b"); + var action = () => service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b"); await Assert.ThrowsAsync(action); Assert.False(store.UpdateBatchDetailsCalled); @@ -349,9 +382,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b"); + await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b"); Assert.True(store.UpdateBatchDetailsCalled); Assert.Equal(batchId, store.LastUpdatedBatchId); @@ -380,9 +414,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(coGmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b"); + await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b"); Assert.True(store.UpdateBatchDetailsCalled); Assert.Equal(batchId, store.LastUpdatedBatchId); @@ -400,9 +435,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", ownerId) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(ownerId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant"); + await service.AddCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString(), "Assistant GM", "assistant"); Assert.True(store.AddCoGmCalled); Assert.Equal(groupId, store.LastAddedCoGmGroupId); @@ -427,9 +463,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(coGmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null); + var action = () => service.AddCoGmForOwnerAsync(groupId, "Telegram", newCoGmId.ToString(), "Second Assistant", null); await Assert.ThrowsAsync(action); Assert.False(store.AddCoGmCalled); @@ -450,9 +487,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(ownerId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId); + await service.RemoveCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString()); Assert.True(store.RemoveCoGmCalled); Assert.Equal(groupId, store.LastRemovedCoGmGroupId); @@ -473,9 +511,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly); + var action = () => service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly); await Assert.ThrowsAsync(action); Assert.False(store.UpdateBatchNotificationModeCalled); @@ -496,9 +535,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly); + await service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly); Assert.True(store.UpdateBatchNotificationModeCalled); Assert.Equal(batchId, store.LastUpdatedNotificationBatchId); @@ -521,9 +561,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0); + var action = () => service.RescheduleBatchForCurrentUserAsync(batchId, DateTime.UtcNow.AddDays(7), intervalDays: 0); await Assert.ThrowsAsync(action); Assert.False(store.RescheduleBatchCalled); @@ -545,9 +586,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14); + await service.RescheduleBatchForCurrentUserAsync(batchId, firstScheduledAt, intervalDays: 14); Assert.True(store.RescheduleBatchCalled); Assert.Equal(batchId, store.LastRescheduledBatchId); @@ -571,9 +613,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek); + await service.CloneBatchForCurrentUserAsync(batchId, BatchCloneInterval.NextWeek); Assert.True(store.CloneBatchCalled); Assert.Equal(batchId, store.LastClonedBatchId); @@ -591,11 +634,11 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", gmId) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CreateCampaignTemplateForGmAsync( + await service.CreateCampaignTemplateForCurrentUserAsync( groupId, - gmId, new CreateCampaignTemplateRequest( " Weekly arc ", " Kingmaker ", @@ -626,11 +669,11 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", gmId) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CreateCampaignTemplateForGmAsync( + await service.CreateCampaignTemplateForCurrentUserAsync( groupId, - gmId, new CreateCampaignTemplateRequest( "Open table", "West Marches", @@ -653,11 +696,11 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", 2002L) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.CreateCampaignTemplateForGmAsync( + var action = () => service.CreateCampaignTemplateForCurrentUserAsync( groupId, - 1001L, new CreateCampaignTemplateRequest( "Weekly arc", "Kingmaker", @@ -687,9 +730,10 @@ public sealed class AuthorizedSessionServiceTests [ new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt); + await service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, firstScheduledAt); Assert.True(store.CreateBatchFromTemplateCalled); Assert.Equal(templateId, store.LastCreatedBatchTemplateId); @@ -711,9 +755,10 @@ public sealed class AuthorizedSessionServiceTests [ new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3)); + var action = () => service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, DateTime.UtcNow.AddDays(3)); await Assert.ThrowsAsync(action); Assert.False(store.CreateBatchFromTemplateCalled); @@ -1019,7 +1064,7 @@ public sealed class AuthorizedSessionServiceTests public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) { - var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow); + var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId.ToString(), actorName, changeType, oldValue, newValue, DateTime.UtcNow); LogEntries.Add(entry); LastLogSessionId = sessionId; LastLogActorTelegramId = actorTelegramId; @@ -1033,12 +1078,62 @@ public sealed class AuthorizedSessionServiceTests public Task> GetSessionHistoryAsync(Guid sessionId) => Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList()); + public Task> GetGroupsForUserAsync(string platform, string externalUserId) => + Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, externalUserId)).ToList()); + + public Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) => + Task.FromResult(IsManager(groupId, externalUserId)); + + public Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => + Task.FromResult(IsOwner(groupId, externalUserId)); + + public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) + { + AddCoGmCalled = true; + LastAddedCoGmGroupId = groupId; + LastAddedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null; + LastAddedCoGmDisplayName = displayName; + LastAddedCoGmUsername = externalUsername; + return Task.CompletedTask; + } + + public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) + { + RemoveCoGmCalled = true; + LastRemovedCoGmGroupId = groupId; + LastRemovedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null; + return Task.CompletedTask; + } + + public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) + { + var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorExternalUserId, actorName, changeType, oldValue, newValue, DateTime.UtcNow); + LogEntries.Add(entry); + LastLogSessionId = sessionId; + LastLogActorTelegramId = long.TryParse(actorExternalUserId, out var id) ? (long?)id : null; + LastLogActorName = actorName; + LastLogChangeType = changeType; + LastLogOldValue = oldValue; + LastLogNewValue = newValue; + return Task.CompletedTask; + } + + public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => + 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 bool IsManager(Guid groupId, string externalUserId) => + IsOwner(groupId, externalUserId) || + managers.Any(manager => manager.GroupId == groupId && manager.TelegramId.ToString() == externalUserId); + + private bool IsOwner(Guid groupId, string externalUserId) => + groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId.ToString() == externalUserId; } private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);