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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:08:10 +03:00
parent 543fc42a6d
commit 9d4256353d
4 changed files with 265 additions and 155 deletions
@@ -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<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> 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<List<WebGameGroup>> GetGroupsForCurrentUserAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return Task.FromResult(new List<WebGameGroup>());
return sessionStore.GetGroupsForUserAsync(identity.Value.Platform, identity.Value.ExternalUserId);
}
public async Task<WebGroupManagement?> 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<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
public async Task<List<WebSession>?> 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<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
public async Task<WebSession?> 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<WebSessionBatch?> GetBatchForGmAsync(Guid batchId, long gmId)
public async Task<WebSessionBatch?> 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<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
public async Task<WebSessionBatch> 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<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
public async Task<List<WebCampaignTemplate>?> 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<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
public async Task<WebCampaignTemplate> 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<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
public async Task<WebSessionBatch> 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<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
public async Task<List<WebParticipant>?> 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<List<SessionAuditLogEntry>?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId)
public async Task<List<SessionAuditLogEntry>?> 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<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
public async Task<List<PlayerAttendanceStats>?> 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)