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; using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services; 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) => private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{ {
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; return null;
}
var group = await sessionStore.GetGroupAsync(groupId); var group = await sessionStore.GetGroupAsync(groupId);
if (group is null) if (group is null)
{
return null; return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId); 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); 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 null;
}
return await sessionStore.GetUpcomingSessionsAsync(groupId); 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); var session = await sessionStore.GetSessionAsync(sessionId);
if (session is null) if (session is null)
{
return 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); var batch = await sessionStore.GetBatchAsync(batchId);
if (batch is null) if (batch is null)
{
return 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) 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); await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
if (session.Title != title) 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) 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) 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) 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) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, gmId); throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
} }
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); 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) 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()); 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) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, gmId); throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
} }
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode); 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) if (intervalDays <= 0)
{ {
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero."); 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) 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.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) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, gmId); throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
} }
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); 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 null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId); return await sessionStore.GetCampaignTemplatesAsync(groupId);
} }
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync( public async Task<WebCampaignTemplate> CreateCampaignTemplateForCurrentUserAsync(
Guid groupId, Guid groupId,
long gmId,
CreateCampaignTemplateRequest request) 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); var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest); 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); 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); await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
} }
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync( public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForCurrentUserAsync(
Guid templateId, Guid templateId,
long gmId,
DateTime firstScheduledAt) DateTime firstScheduledAt)
{ {
if (firstScheduledAt <= DateTime.UtcNow) 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."); 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); 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); 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."); 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) var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}" ? $"{coGmPlatform} {coGmExternalUserId}"
: displayName.Trim(); : displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername) var normalizedUsername = string.IsNullOrWhiteSpace(externalUsername)
? null ? 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) if (session is null)
{
return null; return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId); 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) if (session is null)
{
return null; return null;
}
return await sessionStore.GetSessionHistoryAsync(sessionId); 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) 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.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) private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
+11 -12
View File
@@ -5,33 +5,31 @@ namespace GmRelay.Web.Services;
public sealed record PlayerAttendanceStats( public sealed record PlayerAttendanceStats(
Guid PlayerId, Guid PlayerId,
string DisplayName, string DisplayName,
string? TelegramUsername, string? ExternalUsername,
long TotalSessions, long TotalSessions,
long ConfirmedCount, long ConfirmedCount,
long DeclinedCount, long DeclinedCount,
long NoResponseCount, long NoResponseCount,
long WaitlistedCount, long WaitlistedCount,
long CancellationAffectedCount, long CancellationAffectedCount,
decimal AttendanceRate decimal AttendanceRate);
);
public sealed record SessionAuditLogEntry( public sealed record SessionAuditLogEntry(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
long ActorTelegramId, string ActorExternalUserId,
string ActorName, string ActorName,
string ChangeType, string ChangeType,
string? OldValue, string? OldValue,
string? NewValue, string? NewValue,
DateTime ChangedAt DateTime ChangedAt);
);
public interface ISessionStore public interface ISessionStore
{ {
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId); Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId); Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId); Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId); Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId); Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId); Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId); Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -47,11 +45,12 @@ public interface ISessionStore
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request); Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId); Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt); Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId); Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId); Task<List<PlayerAttendanceStats>> 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<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId); Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
} }
@@ -1,4 +1,4 @@
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId) public sealed class SessionAccessDeniedException(Guid sessionId, string externalUserId)
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'."); : InvalidOperationException($"Session '{sessionId}' is not accessible for user '{externalUserId}'.");
+84 -51
View File
@@ -10,14 +10,17 @@ namespace GmRelay.Web.Services;
public sealed record WebGameGroup( public sealed record WebGameGroup(
Guid Id, Guid Id,
long TelegramChatId, long TelegramChatId,
string? ExternalGroupId,
string Name, string Name,
long GmTelegramId, string? Platform,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue); string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
public sealed record WebGroupManager( public sealed record WebGroupManager(
long TelegramId, long TelegramId,
string? ExternalUserId,
string DisplayName, string DisplayName,
string? TelegramUsername, string? TelegramUsername,
string? ExternalUsername,
string Role, string Role,
DateTime AddedAt); DateTime AddedAt);
@@ -44,8 +47,10 @@ public sealed record WebSession(
public sealed record WebParticipant( public sealed record WebParticipant(
Guid Id, Guid Id,
long TelegramId, long TelegramId,
string? ExternalUserId,
string DisplayName, string DisplayName,
string? TelegramUsername, string? TelegramUsername,
string? ExternalUsername,
string RsvpStatus, string RsvpStatus,
string RegistrationStatus, string RegistrationStatus,
bool IsGm, bool IsGm,
@@ -83,23 +88,25 @@ public sealed class SessionService(
ITelegramBotClient bot, ITelegramBotClient bot,
ILogger<SessionService> logger) : ISessionStore 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(); await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>( return (await conn.QueryAsync<WebGameGroup>(
""" """
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name, g.name,
g.gm_telegram_id AS GmTelegramId, g.platform AS Platform,
gm.role AS ManagerRole gm.role AS ManagerRole
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_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 ORDER BY g.name
""", """,
new { GmId = gmId })).ToList(); new { Platform = platform, ExternalUserId = externalUserId })).ToList();
} }
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId) public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
@@ -109,8 +116,9 @@ public sealed class SessionService(
""" """
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name, g.name,
g.gm_telegram_id AS GmTelegramId, g.platform AS Platform,
@OwnerRole AS ManagerRole @OwnerRole AS ManagerRole
FROM game_groups g FROM game_groups g
WHERE g.id = @GroupId WHERE g.id = @GroupId
@@ -118,7 +126,7 @@ public sealed class SessionService(
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); 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(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -128,13 +136,14 @@ public sealed class SessionService(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId 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(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -144,11 +153,12 @@ public sealed class SessionService(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @OwnerRole 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) public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -157,8 +167,10 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebGroupManager>( return (await conn.QueryAsync<WebGroupManager>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT p.telegram_id AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
gm.role AS Role, gm.role AS Role,
gm.created_at AS AddedAt gm.created_at AS AddedAt
FROM group_managers gm FROM group_managers gm
@@ -179,7 +191,7 @@ public sealed class SessionService(
SELECT SELECT
p.id AS PlayerId, p.id AS PlayerId,
p.display_name AS DisplayName, 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 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 = 'Confirmed' THEN s.id END) AS ConfirmedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount, 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 WHERE s.group_id = @GroupId
AND s.scheduled_at <= now() AND s.scheduled_at <= now()
AND sp.is_gm = false 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 ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""", """,
new { GroupId = groupId })).ToList(); 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 using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync( await conn.ExecuteAsync(
""" """
INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value) INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value)
VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue) 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) public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
@@ -220,7 +232,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var entries = await conn.QueryAsync<SessionAuditLogEntry>( 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 change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
FROM session_audit_log FROM session_audit_log
WHERE session_id = @SessionId WHERE session_id = @SessionId
@@ -230,32 +242,47 @@ public sealed class SessionService(
return entries.ToList(); 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( public async Task AddGroupCoGmAsync(
Guid groupId, Guid groupId,
long ownerTelegramId, string ownerPlatform, string ownerExternalUserId,
long coGmTelegramId, string coGmPlatform, string coGmExternalUserId,
string displayName, string displayName, string? externalUsername)
string? telegramUsername)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync(); await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync( await conn.ExecuteAsync(
""" """
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername) VALUES (@DisplayName, @ExternalUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (telegram_id) DO UPDATE 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, SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username, external_username = EXCLUDED.external_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)
""", """,
new new
{ {
TelegramId = coGmTelegramId,
DisplayName = displayName, DisplayName = displayName,
TelegramUsername = telegramUsername ExternalUsername = externalUsername,
Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId
}, },
transaction); transaction);
@@ -267,8 +294,8 @@ public sealed class SessionService(
@CoGmRole, @CoGmRole,
owner_player.id owner_player.id
FROM players co_gm FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId
WHERE co_gm.telegram_id = @CoGmTelegramId WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId
ON CONFLICT (group_id, player_id) DO UPDATE ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role WHEN group_managers.role = @OwnerRole THEN group_managers.role
@@ -279,8 +306,10 @@ public sealed class SessionService(
new new
{ {
GroupId = groupId, GroupId = groupId,
OwnerTelegramId = ownerTelegramId, OwnerPlatform = ownerPlatform,
CoGmTelegramId = coGmTelegramId, OwnerExternalUserId = ownerExternalUserId,
CoGmPlatform = coGmPlatform,
CoGmExternalUserId = coGmExternalUserId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue, OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue CoGmRole = GroupManagerRoleExtensions.CoGmValue
}, },
@@ -289,7 +318,7 @@ public sealed class SessionService(
await transaction.CommitAsync(); 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 using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync( await conn.ExecuteAsync(
@@ -298,13 +327,15 @@ public sealed class SessionService(
USING players p USING players p
WHERE gm.player_id = p.id WHERE gm.player_id = p.id
AND gm.group_id = @GroupId AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @CoGmRole AND gm.role = @CoGmRole
""", """,
new new
{ {
GroupId = groupId, GroupId = groupId,
CoGmTelegramId = coGmTelegramId, Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue CoGmRole = GroupManagerRoleExtensions.CoGmValue
}); });
} }
@@ -426,7 +457,7 @@ public sealed class SessionService(
if (oldSession is null) if (oldSession is null)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
@@ -454,7 +485,7 @@ public sealed class SessionService(
if (updatedRows == 0) if (updatedRows == 0)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
await conn.ExecuteAsync( await conn.ExecuteAsync(
@@ -513,7 +544,7 @@ public sealed class SessionService(
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
var activeParticipants = await conn.ExecuteScalarAsync<int>( var activeParticipants = await conn.ExecuteScalarAsync<int>(
@@ -598,8 +629,10 @@ public sealed class SessionService(
""" """
SELECT sp.id AS Id, SELECT sp.id AS Id,
p.telegram_id AS TelegramId, p.telegram_id AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm, sp.is_gm AS IsGm,
@@ -637,7 +670,7 @@ public sealed class SessionService(
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>( var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
@@ -744,7 +777,7 @@ public sealed class SessionService(
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
@@ -767,7 +800,7 @@ public sealed class SessionService(
if (updatedRows == 0) if (updatedRows == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
@@ -786,7 +819,7 @@ public sealed class SessionService(
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
@@ -807,7 +840,7 @@ public sealed class SessionService(
if (updatedRows == 0) if (updatedRows == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
@@ -844,7 +877,7 @@ public sealed class SessionService(
if (batchSessions.Count == 0) if (batchSessions.Count == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
@@ -928,7 +961,7 @@ public sealed class SessionService(
if (sourceSessions.Count == 0) if (sourceSessions.Count == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var newBatchId = Guid.NewGuid(); var newBatchId = Guid.NewGuid();
@@ -1130,7 +1163,7 @@ public sealed class SessionService(
if (template is null) if (template is null)
{ {
throw new SessionAccessDeniedException(templateId, 0); throw new SessionAccessDeniedException(templateId, "0");
} }
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>( var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
@@ -1140,7 +1173,7 @@ public sealed class SessionService(
if (group is null) if (group is null)
{ {
throw new SessionAccessDeniedException(groupId, 0); throw new SessionAccessDeniedException(groupId, "0");
} }
var schedule = BatchSchedulePlanner.BuildRecurringSchedule( var schedule = BatchSchedulePlanner.BuildRecurringSchedule(