using System.Security.Claims; using GmRelay.Web.Services.Portfolio.Covers; namespace GmRelay.Web.Services.Portfolio; public sealed class AuthorizedPortfolioService( IPortfolioStore portfolioStore, ISessionStore sessionStore, IPortfolioCoverStorage coverStorage, IHttpContextAccessor httpContextAccessor) { private (string Platform, string ExternalUserId, string? Name)? GetCurrentIdentity() { 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; return (platform, externalUserId, name); } private async Task<(string Platform, string ExternalUserId)> RequireManagerAsync(Guid groupId) { var identity = GetCurrentIdentity(); if (identity is null) { throw new SessionAccessDeniedException(groupId, ""); } if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } return (identity.Value.Platform, identity.Value.ExternalUserId); } private async Task<(Guid GroupId, string Platform, string ExternalUserId)> RequireManagerForGameAsync(Guid portfolioGameId) { var identity = GetCurrentIdentity(); if (identity is null) { throw new SessionAccessDeniedException(portfolioGameId, ""); } var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId); if (groupId is null) { throw new InvalidOperationException("Portfolio game not found."); } if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId)) { throw new SessionAccessDeniedException(portfolioGameId, identity.Value.ExternalUserId); } return (groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId); } // --- Protected reads --- public async Task> GetPortfolioGamesForCurrentUserAsync(Guid groupId) { var identity = GetCurrentIdentity(); if (identity is null) { return []; } if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { return []; } return await portfolioStore.GetPortfolioGamesForGroupAsync(groupId); } public async Task GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId) { var identity = GetCurrentIdentity(); if (identity is null) { return null; } var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId); if (groupId is null) { return null; } if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId)) { return null; } return await portfolioStore.GetPortfolioGameForManagementAsync(portfolioGameId); } public async Task> GetCompletedSessionsForCurrentUserAsync(Guid groupId) { var identity = GetCurrentIdentity(); if (identity is null) { return []; } if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { return []; } return await portfolioStore.GetEligibleCompletedSessionsAsync(groupId, null); } // --- Protected writes --- public async Task CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId) { await RequireManagerAsync(groupId); return await portfolioStore.CreatePortfolioDraftAsync(groupId, preselectedSessionId); } public async Task UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update) { var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); var normalized = NormalizeUpdate(update); await portfolioStore.UpdatePortfolioDraftAsync(portfolioGameId, groupId, normalized); } public async Task ReplaceCoverForCurrentUserAsync( Guid portfolioGameId, Stream content, string contentType, CancellationToken cancellationToken = default) { var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); var saveResult = await coverStorage.SaveAsync(content, contentType, cancellationToken); var newKey = saveResult.StorageKey; try { var oldKey = await portfolioStore.SetPortfolioCoverAsync(portfolioGameId, groupId, newKey); if (!string.IsNullOrWhiteSpace(oldKey)) { await coverStorage.DeleteIfExistsAsync(oldKey, cancellationToken); } } catch { await coverStorage.DeleteIfExistsAsync(newKey, cancellationToken); throw; } } public async Task DeleteForCurrentUserAsync(Guid portfolioGameId) { var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); var coverKey = await portfolioStore.DeletePortfolioGameAsync(portfolioGameId, groupId); if (!string.IsNullOrWhiteSpace(coverKey)) { await coverStorage.DeleteIfExistsAsync(coverKey); } } public async Task SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic) { var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); await portfolioStore.SetPortfolioPublicationAsync(portfolioGameId, groupId, isPublic); } public async Task ModerateReviewForCurrentUserAsync( Guid portfolioGameId, Guid reviewId, string moderationStatus) { var (groupId, platform, externalUserId) = await RequireManagerForGameAsync(portfolioGameId); var moderatorPlayerId = await sessionStore.ResolveEffectivePlayerIdAsync(platform, externalUserId); if (moderatorPlayerId is null) { throw new InvalidOperationException("Authenticated player not found."); } await portfolioStore.ModeratePortfolioReviewAsync( reviewId, portfolioGameId, groupId, moderatorPlayerId.Value, moderationStatus); } // --- Review submission --- public async Task GetReviewSubmissionStateForCurrentUserAsync(string slug) { var identity = GetCurrentIdentity(); if (identity is null) { return PortfolioReviewSubmissionState.RequiresAuthentication; } return await portfolioStore.GetReviewSubmissionStateAsync(slug, identity.Value.Platform, identity.Value.ExternalUserId); } public async Task SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent) { if (!publicationConsent) { throw new InvalidOperationException("Public review requires explicit consent."); } var identity = GetCurrentIdentity(); if (identity is null) { throw new SessionAccessDeniedException(Guid.Empty, ""); } var normalizedSlug = PortfolioValidation.NormalizeSlug(slug); var normalizedBody = PortfolioValidation.NormalizeReviewBody(body); var displayName = identity.Value.Name?.Trim() ?? identity.Value.ExternalUserId; if (displayName.Length == 0) { throw new InvalidOperationException("Display name is required."); } await portfolioStore.SubmitPortfolioReviewAsync( normalizedSlug, identity.Value.Platform, identity.Value.ExternalUserId, displayName, normalizedBody); } // --- Internal helpers --- private static PortfolioGameUpdate NormalizeUpdate(PortfolioGameUpdate update) { var title = PortfolioValidation.NormalizeTitle(update.Title); var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug); var description = PortfolioValidation.NormalizeDescription(update.Description); var format = PortfolioValidation.NormalizeFormat(update.Format); var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim(); return update with { Title = title, PublicSlug = slug, Description = description, System = system, Format = format }; } }