From 9d4256353df516fa9767b894f283812848b95c55 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 25 May 2026 11:08:10 +0300 Subject: [PATCH] feat(web): refactor SessionStore and AuthorizedSessionService to platform-agnostic identity - ISessionStore: all methods use (platform, external_user_id) - SessionService: updated SQL queries and added UpsertDiscordUserAsync - AuthorizedSessionService: resolves identity from HttpContext, no longer accepts telegram_id params - SessionAccessDeniedException now accepts string externalUserId - Added ExternalUserId/ExternalUsername to WebGroupManager and WebParticipant Co-Authored-By: Claude Opus 4.7 --- .../Services/AuthorizedSessionService.cs | 258 ++++++++++++------ src/GmRelay.Web/Services/ISessionStore.cs | 23 +- .../Services/SessionAccessDeniedException.cs | 4 +- src/GmRelay.Web/Services/SessionService.cs | 135 +++++---- 4 files changed, 265 insertions(+), 155 deletions(-) diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 76f32fb..f091fa9 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -1,182 +1,237 @@ +using System.Security.Claims; using GmRelay.Shared.Domain; namespace GmRelay.Web.Services; -public sealed class AuthorizedSessionService(ISessionStore sessionStore) +public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor) { - public Task> GetGroupsForGmAsync(long gmId) => - sessionStore.GetGroupsForGmAsync(gmId); - - public async Task GetGroupManagementForGmAsync(Guid groupId, long gmId) + private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity() { - if (!await GroupBelongsToGmAsync(groupId, gmId)) - { + var user = httpContextAccessor.HttpContext?.User; + if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId)) + return null; + + var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId; + return (platform, externalUserId, name); + } + + public Task> GetGroupsForCurrentUserAsync() + { + var identity = GetCurrentIdentity(); + if (identity is null) + return Task.FromResult(new List()); + + return sessionStore.GetGroupsForUserAsync(identity.Value.Platform, identity.Value.ExternalUserId); + } + + public async Task GetGroupManagementForCurrentUserAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) 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); + var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId); return new WebGroupManagement(group, managers, isOwner); } - public async Task?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) + public async Task?> GetUpcomingSessionsForCurrentUserAsync(Guid groupId) { - if (!await GroupBelongsToGmAsync(groupId, gmId)) - { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) return null; - } return await sessionStore.GetUpcomingSessionsAsync(groupId); } - public async Task GetSessionForGmAsync(Guid sessionId, long gmId) + public async Task GetSessionForCurrentUserAsync(Guid sessionId) { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + var session = await sessionStore.GetSessionAsync(sessionId); if (session is null) - { return null; - } - return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; + return await sessionStore.IsGroupManagerAsync(session.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? session : null; } - public async Task GetBatchForGmAsync(Guid batchId, long gmId) + public async Task GetBatchForCurrentUserAsync(Guid batchId) { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + var batch = await sessionStore.GetBatchAsync(batchId); if (batch is null) - { return null; - } - return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null; + return await sessionStore.IsGroupManagerAsync(batch.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? batch : null; } - public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) + public async Task UpdateSessionForCurrentUserAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) { - throw new SessionAccessDeniedException(sessionId, gmId); + throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); } await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers); if (session.Title != title) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Title", session.Title, title); if (session.ScheduledAt != scheduledAt) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O")); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O")); if (session.JoinLink != joinLink) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Link", session.JoinLink, joinLink); if (session.MaxPlayers != maxPlayers) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString()); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString()); } - public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId) + public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) { - throw new SessionAccessDeniedException(sessionId, gmId); + throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); } await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "WaitlistPromote", null, null); } - public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink) + public async Task UpdateBatchDetailsForCurrentUserAsync(Guid batchId, string title, string joinLink) { - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim()); } - public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode) + public async Task UpdateBatchNotificationModeForCurrentUserAsync(Guid batchId, SessionNotificationMode notificationMode) { - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode); } - public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays) + public async Task RescheduleBatchForCurrentUserAsync(Guid batchId, DateTime firstScheduledAt, int intervalDays) { if (intervalDays <= 0) { throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero."); } - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays); - await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O")); + await sessionStore.LogSessionChangeAsync(batchId, identity.Value.ExternalUserId, identity.Value.Name, "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O")); } - public async Task CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval) + public async Task CloneBatchForCurrentUserAsync(Guid batchId, BatchCloneInterval interval) { - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); } - public async Task?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId) + public async Task?> GetCampaignTemplatesForCurrentUserAsync(Guid groupId) { - if (!await GroupBelongsToGmAsync(groupId, gmId)) - { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) return null; - } return await sessionStore.GetCampaignTemplatesAsync(groupId); } - public async Task CreateCampaignTemplateForGmAsync( + public async Task CreateCampaignTemplateForCurrentUserAsync( Guid groupId, - long gmId, CreateCampaignTemplateRequest request) { - if (!await GroupBelongsToGmAsync(groupId, gmId)) + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(groupId, gmId); + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } var normalizedRequest = NormalizeCampaignTemplateRequest(request); return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest); } - public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId) + public async Task DeleteCampaignTemplateForCurrentUserAsync(Guid templateId) { + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + var template = await sessionStore.GetCampaignTemplateAsync(templateId); - if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) + if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(templateId, gmId); + throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId); } await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId); } - public async Task CreateBatchFromCampaignTemplateForGmAsync( + public async Task CreateBatchFromCampaignTemplateForCurrentUserAsync( Guid templateId, - long gmId, DateTime firstScheduledAt) { if (firstScheduledAt <= DateTime.UtcNow) @@ -184,89 +239,112 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future."); } + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + var template = await sessionStore.GetCampaignTemplateAsync(templateId); - if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) + if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(templateId, gmId); + throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId); } return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt); } - public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) + public async Task AddCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) { - if (coGmTelegramId <= 0) + if (string.IsNullOrWhiteSpace(coGmExternalUserId)) { - throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero."); + throw new ArgumentException("External user id must not be empty.", nameof(coGmExternalUserId)); } - if (ownerTelegramId == coGmTelegramId) + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + if (identity.Value.ExternalUserId == coGmExternalUserId && identity.Value.Platform == coGmPlatform) { throw new InvalidOperationException("Owner is already a group manager."); } - if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) + if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(groupId, ownerTelegramId); + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } var normalizedName = string.IsNullOrWhiteSpace(displayName) - ? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + ? $"{coGmPlatform} {coGmExternalUserId}" : displayName.Trim(); - var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername) + var normalizedUsername = string.IsNullOrWhiteSpace(externalUsername) ? null - : telegramUsername.Trim().TrimStart('@'); + : externalUsername.Trim().TrimStart('@'); - await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername); + await sessionStore.AddGroupCoGmAsync( + groupId, + identity.Value.Platform, identity.Value.ExternalUserId, + coGmPlatform, coGmExternalUserId, + normalizedName, normalizedUsername); } - public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId) + public async Task RemoveCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) { - if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(groupId, ownerTelegramId); + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } - await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId); + await sessionStore.RemoveGroupCoGmAsync(groupId, coGmPlatform, coGmExternalUserId); } - public async Task?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) + public async Task?> GetSessionParticipantsForCurrentUserAsync(Guid sessionId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) - { return null; - } return await sessionStore.GetSessionParticipantsAsync(sessionId); } - public async Task?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId) + public async Task?> GetSessionHistoryForCurrentUserAsync(Guid sessionId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) - { return null; - } return await sessionStore.GetSessionHistoryAsync(sessionId); } - public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) + public async Task RemovePlayerFromSessionForCurrentUserAsync(Guid sessionId, Guid participantId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) { - throw new SessionAccessDeniedException(sessionId, gmId); + throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); } await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "PlayerRemoved", participantId.ToString(), null); } - private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) + public async Task?> GetGroupAttendanceStatsForCurrentUserAsync(Guid groupId) { - return await sessionStore.IsGroupManagerAsync(groupId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + return null; + + return await sessionStore.GetGroupAttendanceStatsAsync(groupId); } private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request) diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index aebbbdf..c1745f7 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -5,33 +5,31 @@ namespace GmRelay.Web.Services; public sealed record PlayerAttendanceStats( Guid PlayerId, string DisplayName, - string? TelegramUsername, + string? ExternalUsername, long TotalSessions, long ConfirmedCount, long DeclinedCount, long NoResponseCount, long WaitlistedCount, long CancellationAffectedCount, - decimal AttendanceRate -); + decimal AttendanceRate); public sealed record SessionAuditLogEntry( Guid Id, Guid SessionId, - long ActorTelegramId, + string ActorExternalUserId, string ActorName, string ChangeType, string? OldValue, string? NewValue, - DateTime ChangedAt -); + DateTime ChangedAt); public interface ISessionStore { - Task> GetGroupsForGmAsync(long gmId); + Task> GetGroupsForUserAsync(string platform, string externalUserId); Task GetGroupAsync(Guid groupId); - Task IsGroupManagerAsync(Guid groupId, long telegramId); - Task IsGroupOwnerAsync(Guid groupId, long telegramId); + Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId); + Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId); Task> GetGroupManagersAsync(Guid groupId); Task> GetUpcomingSessionsAsync(Guid groupId); Task GetSessionAsync(Guid sessionId); @@ -47,11 +45,12 @@ public interface ISessionStore Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request); Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId); Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt); - Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); - Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); + Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername); + Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId); Task> GetSessionParticipantsAsync(Guid sessionId); Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); Task> GetGroupAttendanceStatsAsync(Guid groupId); - Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue); + Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue); Task> GetSessionHistoryAsync(Guid sessionId); + Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl); } diff --git a/src/GmRelay.Web/Services/SessionAccessDeniedException.cs b/src/GmRelay.Web/Services/SessionAccessDeniedException.cs index 9b30b1a..44e5462 100644 --- a/src/GmRelay.Web/Services/SessionAccessDeniedException.cs +++ b/src/GmRelay.Web/Services/SessionAccessDeniedException.cs @@ -1,4 +1,4 @@ namespace GmRelay.Web.Services; -public sealed class SessionAccessDeniedException(Guid sessionId, long gmId) - : InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'."); +public sealed class SessionAccessDeniedException(Guid sessionId, string externalUserId) + : InvalidOperationException($"Session '{sessionId}' is not accessible for user '{externalUserId}'."); diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 42ee77a..cf02c64 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -10,14 +10,17 @@ namespace GmRelay.Web.Services; public sealed record WebGameGroup( Guid Id, long TelegramChatId, + string? ExternalGroupId, string Name, - long GmTelegramId, + string? Platform, string ManagerRole = GroupManagerRoleExtensions.OwnerValue); public sealed record WebGroupManager( long TelegramId, + string? ExternalUserId, string DisplayName, string? TelegramUsername, + string? ExternalUsername, string Role, DateTime AddedAt); @@ -44,8 +47,10 @@ public sealed record WebSession( public sealed record WebParticipant( Guid Id, long TelegramId, + string? ExternalUserId, string DisplayName, string? TelegramUsername, + string? ExternalUsername, string RsvpStatus, string RegistrationStatus, bool IsGm, @@ -83,23 +88,25 @@ public sealed class SessionService( ITelegramBotClient bot, ILogger logger) : ISessionStore { - public async Task> GetGroupsForGmAsync(long gmId) + public async Task> GetGroupsForUserAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, + g.external_group_id AS ExternalGroupId, g.name, - g.gm_telegram_id AS GmTelegramId, + g.platform AS Platform, 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 + WHERE p.platform = @Platform + AND p.external_user_id = @ExternalUserId ORDER BY g.name """, - new { GmId = gmId })).ToList(); + new { Platform = platform, ExternalUserId = externalUserId })).ToList(); } public async Task GetGroupAsync(Guid groupId) @@ -109,8 +116,9 @@ public sealed class SessionService( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, + g.external_group_id AS ExternalGroupId, g.name, - g.gm_telegram_id AS GmTelegramId, + g.platform AS Platform, @OwnerRole AS ManagerRole FROM game_groups g WHERE g.id = @GroupId @@ -118,7 +126,7 @@ public sealed class SessionService( new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } - public async Task IsGroupManagerAsync(Guid groupId, long telegramId) + public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.ExecuteScalarAsync( @@ -128,13 +136,14 @@ public sealed class SessionService( FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId - AND p.telegram_id = @TelegramId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId ) """, - new { GroupId = groupId, TelegramId = telegramId }); + new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId }); } - public async Task IsGroupOwnerAsync(Guid groupId, long telegramId) + public async Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.ExecuteScalarAsync( @@ -144,11 +153,12 @@ public sealed class SessionService( FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId - AND p.telegram_id = @TelegramId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId AND gm.role = @OwnerRole ) """, - new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); + new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task> GetGroupManagersAsync(Guid groupId) @@ -157,8 +167,10 @@ public sealed class SessionService( return (await conn.QueryAsync( """ SELECT p.telegram_id AS TelegramId, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, gm.role AS Role, gm.created_at AS AddedAt FROM group_managers gm @@ -179,7 +191,7 @@ public sealed class SessionService( SELECT p.id AS PlayerId, p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, COUNT(DISTINCT s.id) AS TotalSessions, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount, @@ -198,21 +210,21 @@ public sealed class SessionService( WHERE s.group_id = @GroupId AND s.scheduled_at <= now() AND sp.is_gm = false - GROUP BY p.id, p.display_name, p.telegram_username + GROUP BY p.id, p.display_name, p.external_username, p.telegram_username ORDER BY AttendanceRate DESC, ConfirmedCount DESC """, new { GroupId = groupId })).ToList(); } - public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) + public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( """ - INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value) - VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue) + INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value) + VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue) """, - new { SessionId = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue }); + new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue }); } public async Task> GetSessionHistoryAsync(Guid sessionId) @@ -220,7 +232,7 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); var entries = await conn.QueryAsync( """ - SELECT id, session_id AS SessionId, actor_telegram_id AS ActorTelegramId, actor_name AS ActorName, + SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName, change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt FROM session_audit_log WHERE session_id = @SessionId @@ -230,32 +242,47 @@ public sealed class SessionService( return entries.ToList(); } + public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + """ + INSERT INTO players (display_name, platform, external_user_id, external_username) + VALUES (@DisplayName, 'Discord', @DiscordId, @DisplayName) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE + SET display_name = EXCLUDED.display_name, + external_username = EXCLUDED.external_username + """, + new { DisplayName = displayName, DiscordId = discordId }); + } + public async Task AddGroupCoGmAsync( Guid groupId, - long ownerTelegramId, - long coGmTelegramId, - string displayName, - string? telegramUsername) + string ownerPlatform, string ownerExternalUserId, + string coGmPlatform, string coGmExternalUserId, + string displayName, string? externalUsername) { 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, platform, external_user_id, external_username) - VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername) - ON CONFLICT (telegram_id) DO UPDATE + INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username) + VALUES (@DisplayName, @ExternalUsername, @Platform, @ExternalUserId, @ExternalUsername) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE SET display_name = EXCLUDED.display_name, - telegram_username = EXCLUDED.telegram_username, - platform = COALESCE(players.platform, 'Telegram'), - external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT), - external_username = COALESCE(players.external_username, EXCLUDED.telegram_username) + external_username = EXCLUDED.external_username """, new { - TelegramId = coGmTelegramId, DisplayName = displayName, - TelegramUsername = telegramUsername + ExternalUsername = externalUsername, + Platform = coGmPlatform, + ExternalUserId = coGmExternalUserId }, transaction); @@ -267,8 +294,8 @@ public sealed class SessionService( @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 + LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId + WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId ON CONFLICT (group_id, player_id) DO UPDATE SET role = CASE WHEN group_managers.role = @OwnerRole THEN group_managers.role @@ -279,8 +306,10 @@ public sealed class SessionService( new { GroupId = groupId, - OwnerTelegramId = ownerTelegramId, - CoGmTelegramId = coGmTelegramId, + OwnerPlatform = ownerPlatform, + OwnerExternalUserId = ownerExternalUserId, + CoGmPlatform = coGmPlatform, + CoGmExternalUserId = coGmExternalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue, CoGmRole = GroupManagerRoleExtensions.CoGmValue }, @@ -289,7 +318,7 @@ public sealed class SessionService( await transaction.CommitAsync(); } - public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId) + public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( @@ -298,13 +327,15 @@ public sealed class SessionService( USING players p WHERE gm.player_id = p.id AND gm.group_id = @GroupId - AND p.telegram_id = @CoGmTelegramId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId AND gm.role = @CoGmRole """, new { GroupId = groupId, - CoGmTelegramId = coGmTelegramId, + Platform = coGmPlatform, + ExternalUserId = coGmExternalUserId, CoGmRole = GroupManagerRoleExtensions.CoGmValue }); } @@ -426,7 +457,7 @@ public sealed class SessionService( if (oldSession is null) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } var updatedRows = await conn.ExecuteAsync( @@ -454,7 +485,7 @@ public sealed class SessionService( if (updatedRows == 0) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } await conn.ExecuteAsync( @@ -513,7 +544,7 @@ public sealed class SessionService( if (session is null) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } var activeParticipants = await conn.ExecuteScalarAsync( @@ -598,8 +629,10 @@ public sealed class SessionService( """ SELECT sp.id AS Id, p.telegram_id AS TelegramId, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, sp.rsvp_status AS RsvpStatus, sp.registration_status AS RegistrationStatus, sp.is_gm AS IsGm, @@ -637,7 +670,7 @@ public sealed class SessionService( if (session is null) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } var participant = await conn.QuerySingleOrDefaultAsync( @@ -744,7 +777,7 @@ public sealed class SessionService( var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var updatedRows = await conn.ExecuteAsync( @@ -767,7 +800,7 @@ public sealed class SessionService( if (updatedRows == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } await transaction.CommitAsync(); @@ -786,7 +819,7 @@ public sealed class SessionService( var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var updatedRows = await conn.ExecuteAsync( @@ -807,7 +840,7 @@ public sealed class SessionService( if (updatedRows == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } await transaction.CommitAsync(); @@ -844,7 +877,7 @@ public sealed class SessionService( if (batchSessions.Count == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( @@ -928,7 +961,7 @@ public sealed class SessionService( if (sourceSessions.Count == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var newBatchId = Guid.NewGuid(); @@ -1130,7 +1163,7 @@ public sealed class SessionService( if (template is null) { - throw new SessionAccessDeniedException(templateId, 0); + throw new SessionAccessDeniedException(templateId, "0"); } var group = await conn.QuerySingleOrDefaultAsync( @@ -1140,7 +1173,7 @@ public sealed class SessionService( if (group is null) { - throw new SessionAccessDeniedException(groupId, 0); + throw new SessionAccessDeniedException(groupId, "0"); } var schedule = BatchSchedulePlanner.BuildRecurringSchedule(