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
+84 -51
View File
@@ -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<SessionService> logger) : ISessionStore
{
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>(
"""
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<WebGameGroup?> 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<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
@@ -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<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
@@ -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<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -157,8 +167,10 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebGroupManager>(
"""
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<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
@@ -220,7 +232,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
var entries = await conn.QueryAsync<SessionAuditLogEntry>(
"""
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<int>(
@@ -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<WebParticipant>(
@@ -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<WebTemplateGroupDto>(
@@ -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(