feat(web): authorize portfolio management and reviews

This commit is contained in:
Claude
2026-06-02 15:01:29 +03:00
parent f2c9f34ab4
commit 242ff99a83
3 changed files with 1116 additions and 0 deletions
+1
View File
@@ -47,6 +47,7 @@ builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
builder.Services.AddScoped<AuthorizedPortfolioService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -0,0 +1,258 @@
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, "<anonymous>");
}
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, "<anonymous>");
}
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<IReadOnlyList<PortfolioGameSummary>> 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<PortfolioGameEditor?> 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<IReadOnlyList<PortfolioSessionOption>> 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<Guid> 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<PortfolioReviewSubmissionState> 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, "<anonymous>");
}
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
};
}
}