feat(web): define portfolio contracts and validation

This commit is contained in:
2026-06-02 12:08:05 +03:00
parent 4af4e52778
commit 7d1489445e
5 changed files with 501 additions and 0 deletions
@@ -0,0 +1,36 @@
namespace GmRelay.Web.Services.Portfolio;
public interface IPortfolioStore
{
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
}
@@ -0,0 +1,90 @@
namespace GmRelay.Web.Services.Portfolio;
public sealed record PublicPortfolioCard(
string Slug,
string Title,
string CoverPath,
string? System,
string? Format,
DateTime CompletedAt);
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
public sealed record PublicPortfolioReview(
string AuthorDisplayName,
string Body,
DateTime CreatedAt);
public sealed record PublicPortfolioGame(
string Slug,
string Title,
string Description,
string CoverPath,
string? System,
string? Format,
DateTime CompletedAt,
string? ClubName,
string? ClubSlug,
IReadOnlyList<PublicPortfolioMaster> Masters,
IReadOnlyList<PublicPortfolioReview> Reviews);
public sealed record PortfolioGameSummary(
Guid Id,
Guid GroupId,
string Title,
string? PublicSlug,
bool IsPublic,
DateTime CompletedAt,
int SessionCount,
int MasterCount,
int PendingReviewCount);
public sealed record PortfolioSessionOption(
Guid Id,
string Title,
DateTime ScheduledAt,
bool Selected);
public sealed record PortfolioMasterOption(
Guid PlayerId,
string DisplayName,
bool Selected);
public sealed record PortfolioReviewForModeration(
Guid Id,
string AuthorDisplayName,
string Body,
string ModerationStatus,
DateTime CreatedAt);
public sealed record PortfolioGameEditor(
Guid Id,
Guid GroupId,
string Title,
string? PublicSlug,
string? Description,
string? CoverPath,
string? System,
string? Format,
DateTime CompletedAt,
bool IsPublic,
IReadOnlyList<PortfolioSessionOption> Sessions,
IReadOnlyList<PortfolioMasterOption> Masters,
IReadOnlyList<PortfolioReviewForModeration> Reviews);
public sealed record PortfolioGameUpdate(
string Title,
string? PublicSlug,
string? Description,
string? System,
string? Format,
IReadOnlyList<Guid> SessionIds,
IReadOnlyList<Guid> MasterPlayerIds);
public enum PortfolioReviewSubmissionState
{
RequiresAuthentication,
Ineligible,
Eligible,
AlreadySubmitted
}
@@ -0,0 +1,152 @@
using System.Text;
namespace GmRelay.Web.Services.Portfolio;
public static class PortfolioValidation
{
private const int MinSlugLength = 3;
private const int MaxSlugLength = 160;
private const int MinTitleLength = 2;
private const int MaxTitleLength = 255;
private const int MaxDescriptionLength = 5000;
private const int MinReviewBodyLength = 10;
private const int MaxReviewBodyLength = 2000;
private static readonly HashSet<string> AllowedFormats = new(StringComparer.Ordinal)
{
"Online",
"Offline",
"Hybrid"
};
public static string NormalizeSlug(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException("Slug must not be empty.");
}
var trimmed = value.Trim().ToLowerInvariant();
var builder = new StringBuilder(trimmed.Length);
var previousWasHyphen = false;
foreach (var raw in trimmed)
{
char c;
if (raw == ' ' || raw == '_' || raw == '-')
{
c = '-';
}
else if (IsAsciiAlphanumeric(raw))
{
c = raw;
}
else
{
throw new InvalidOperationException($"Slug contains unsupported character: '{raw}'.");
}
if (c == '-')
{
if (builder.Length == 0 || previousWasHyphen)
{
continue;
}
builder.Append('-');
previousWasHyphen = true;
}
else
{
builder.Append(c);
previousWasHyphen = false;
}
}
while (builder.Length > 0 && builder[^1] == '-')
{
builder.Length--;
}
if (builder.Length < MinSlugLength || builder.Length > MaxSlugLength)
{
throw new InvalidOperationException(
$"Slug length must be between {MinSlugLength} and {MaxSlugLength} characters.");
}
// The normalization loop guarantees the output matches ^[a-z0-9]+(?:-[a-z0-9]+)*$,
// so no post-loop regex check is required.
return builder.ToString();
}
public static string NormalizeTitle(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException("Title must not be empty.");
}
var trimmed = value.Trim();
if (trimmed.Length < MinTitleLength || trimmed.Length > MaxTitleLength)
{
throw new InvalidOperationException(
$"Title length must be between {MinTitleLength} and {MaxTitleLength} characters.");
}
return trimmed;
}
public static string? NormalizeDescription(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (trimmed.Length > MaxDescriptionLength)
{
throw new InvalidOperationException(
$"Description must be at most {MaxDescriptionLength} characters.");
}
return trimmed;
}
public static string NormalizeReviewBody(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException("Review body must not be empty.");
}
var trimmed = value.Trim();
if (trimmed.Length < MinReviewBodyLength || trimmed.Length > MaxReviewBodyLength)
{
throw new InvalidOperationException(
$"Review body length must be between {MinReviewBodyLength} and {MaxReviewBodyLength} characters.");
}
return trimmed;
}
public static string? NormalizeFormat(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (!AllowedFormats.Contains(trimmed))
{
throw new InvalidOperationException(
$"Format must be one of: {string.Join(", ", AllowedFormats)}.");
}
return trimmed;
}
private static bool IsAsciiAlphanumeric(char c) =>
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}