From f2c9f34ab4e6f70c4f454beee7d83e0eb3183ece Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 13:04:41 +0300 Subject: [PATCH] feat(web): add portfolio persistence --- src/GmRelay.Web/Program.cs | 2 + .../Services/Portfolio/PortfolioService.cs | 1109 +++++++++++++++++ .../Web/PortfolioServiceSourceTests.cs | 95 ++ 3 files changed, 1206 insertions(+) create mode 100644 src/GmRelay.Web/Services/Portfolio/PortfolioService.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 790cf97..74030e5 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -2,6 +2,7 @@ using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio; using GmRelay.Web.Services.Portfolio.Covers; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; @@ -45,6 +46,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Add Bot Client builder.Services.AddSingleton(sp => diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs new file mode 100644 index 0000000..569d97a --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs @@ -0,0 +1,1109 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio.Covers; +using Npgsql; +using System.Data; + +namespace GmRelay.Web.Services.Portfolio; + +public sealed class PortfolioService( + NpgsqlDataSource dataSource, + IPortfolioCoverStorage coverStorage) : IPortfolioStore +{ + // --- Public reads --- + + public async Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt + FROM portfolio_games pg + JOIN portfolio_game_masters pgm ON pgm.portfolio_game_id = pg.id + JOIN players p ON p.id = pgm.player_id + JOIN master_profiles mp ON mp.player_id = p.id + WHERE pg.is_public = true + AND mp.is_public = true + AND mp.public_slug IS NOT NULL + AND lower(mp.public_slug) = lower(@MasterSlug) + ORDER BY pg.completed_at DESC + """, + new { MasterSlug = masterSlug }); + + return rows.Select(MapToPublicCard).ToList(); + } + + public async Task> GetPublicPortfolioGamesForClubAsync(string clubSlug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt + FROM portfolio_games pg + JOIN game_groups g ON g.id = pg.group_id + WHERE pg.is_public = true + AND g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND lower(g.public_slug) = lower(@ClubSlug) + ORDER BY pg.completed_at DESC + """, + new { ClubSlug = clubSlug }); + + return rows.Select(MapToPublicCard).ToList(); + } + + public async Task GetPublicPortfolioGameBySlugAsync(string slug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var detail = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + COALESCE(NULLIF(g.name, g.external_group_id), NULL) AS ClubName, + CASE + WHEN g.public_schedule_enabled = true AND g.public_slug IS NOT NULL + THEN g.public_slug + ELSE NULL + END AS ClubSlug + FROM portfolio_games pg + JOIN game_groups g ON g.id = pg.group_id + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (detail is null) + { + return null; + } + + var masters = (await conn.QueryAsync( + """ + SELECT mp.public_slug AS Slug, + mp.display_name AS DisplayName + FROM portfolio_game_masters pgm + JOIN master_profiles mp ON mp.player_id = pgm.player_id + WHERE pgm.portfolio_game_id = @PortfolioGameId + AND mp.is_public = true + AND mp.public_slug IS NOT NULL + ORDER BY mp.display_name + """, + new { PortfolioGameId = detail.Id })).ToList(); + + var reviews = (await conn.QueryAsync( + """ + SELECT r.author_display_name AS AuthorDisplayName, + r.body AS Body, + r.created_at AS CreatedAt + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.moderation_status = 'Approved' + AND r.publication_consent_at IS NOT NULL + ORDER BY r.created_at DESC + """, + new { PortfolioGameId = detail.Id })).ToList(); + + return new PublicPortfolioGame( + detail.Slug!, + detail.Title, + detail.Description ?? string.Empty, + string.IsNullOrEmpty(detail.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(detail.CoverStorageKey), + detail.System, + detail.Format, + detail.CompletedAt, + detail.ClubSlug is null ? null : detail.ClubName, + detail.ClubSlug, + masters.Select(m => new PublicPortfolioMaster(m.Slug!, m.DisplayName)).ToList(), + reviews.Select(r => new PublicPortfolioReview(r.AuthorDisplayName, r.Body, r.CreatedAt)).ToList()); + } + + // --- Protected reads --- + + public async Task> GetPortfolioGamesForGroupAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.is_public AS IsPublic, + pg.completed_at AS CompletedAt, + COALESCE(session_counts.count, 0)::int AS SessionCount, + COALESCE(master_counts.count, 0)::int AS MasterCount, + COALESCE(pending_counts.count, 0)::int AS PendingReviewCount + FROM portfolio_games pg + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = pg.id + ) session_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = pg.id + ) master_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = pg.id + AND r.moderation_status = 'Pending' + ) pending_counts ON true + WHERE pg.group_id = @GroupId + ORDER BY pg.completed_at DESC, pg.created_at DESC + """, + new { GroupId = groupId }); + + return rows.Select(r => new PortfolioGameSummary( + r.Id, + r.GroupId, + r.Title, + r.PublicSlug, + r.IsPublic, + r.CompletedAt, + r.SessionCount, + r.MasterCount, + r.PendingReviewCount)).ToList(); + } + + public async Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT group_id FROM portfolio_games WHERE id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }); + } + + public async Task GetPortfolioGameForManagementAsync(Guid portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var header = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }); + + if (header is null) + { + return null; + } + + var sessions = (await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND pgs.session_id = s.id + ) AS Selected + FROM sessions s + WHERE s.group_id = @GroupId + AND s.scheduled_at < now() + ORDER BY s.scheduled_at DESC + """, + new { PortfolioGameId = header.Id, GroupId = header.GroupId })).ToList(); + + var masters = (await conn.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = @PortfolioGameId + AND pgm.player_id = p.id + ) AS Selected + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + p.display_name + """, + new + { + PortfolioGameId = header.Id, + GroupId = header.GroupId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue + })).ToList(); + + var reviews = (await conn.QueryAsync( + """ + SELECT r.id AS Id, + r.author_display_name AS AuthorDisplayName, + r.body AS Body, + r.moderation_status AS ModerationStatus, + r.created_at AS CreatedAt + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + ORDER BY r.created_at DESC + """, + new { PortfolioGameId = header.Id })).ToList(); + + return new PortfolioGameEditor( + header.Id, + header.GroupId, + header.Title, + header.PublicSlug, + header.Description, + header.CoverStorageKey is null ? null : coverStorage.GetPublicPath(header.CoverStorageKey), + header.System, + header.Format, + header.CompletedAt, + header.IsPublic, + sessions.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(), + masters.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(), + reviews.Select(r => new PortfolioReviewForModeration(r.Id, r.AuthorDisplayName, r.Body, r.ModerationStatus, r.CreatedAt)).ToList()); + } + + public async Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + COALESCE(linked.Selected, false) AS Selected + FROM sessions s + LEFT JOIN LATERAL ( + SELECT true AS Selected + FROM portfolio_game_sessions pgs + WHERE pgs.session_id = s.id + AND (@PortfolioGameId IS NULL OR pgs.portfolio_game_id = @PortfolioGameId) + ) linked ON true + WHERE s.group_id = @GroupId + AND s.scheduled_at < now() + ORDER BY s.scheduled_at DESC + """, + new { GroupId = groupId, PortfolioGameId = portfolioGameId }); + + return rows.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(); + } + + public async Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + COALESCE(linked.Selected, false) AS Selected + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + LEFT JOIN LATERAL ( + SELECT true AS Selected + FROM portfolio_game_masters pgm + WHERE pgm.player_id = p.id + AND (@PortfolioGameId IS NULL OR pgm.portfolio_game_id = @PortfolioGameId) + ) linked ON true + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + p.display_name + """, + new + { + GroupId = groupId, + PortfolioGameId = portfolioGameId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue + }); + + return rows.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(); + } + + // --- Protected writes --- + + public async Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var newId = await conn.ExecuteScalarAsync( + """ + INSERT INTO portfolio_games (group_id, title) + VALUES (@GroupId, 'New adventure') + RETURNING id + """, + new { GroupId = groupId }, + transaction); + + if (preselectedSessionId is not null) + { + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + SELECT @PortfolioGameId, s.id + FROM sessions s + WHERE s.id = @SessionId + AND s.group_id = @GroupId + AND s.scheduled_at < now() + AND NOT EXISTS ( + SELECT 1 FROM portfolio_game_sessions pgs WHERE pgs.session_id = s.id + ) + """, + new + { + PortfolioGameId = newId, + SessionId = preselectedSessionId.Value, + GroupId = groupId + }, + transaction); + } + + await transaction.CommitAsync(); + return newId; + } + + public async Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, 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 system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim(); + var format = PortfolioValidation.NormalizeFormat(update.Format); + + var sessionIds = update.SessionIds?.Distinct().ToArray() ?? Array.Empty(); + var masterPlayerIds = update.MasterPlayerIds?.Distinct().ToArray() ?? Array.Empty(); + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var existing = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (existing is null) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + + try + { + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET title = @Title, + public_slug = @PublicSlug, + description = @Description, + system = @System, + format = @Format, + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new + { + PortfolioGameId = portfolioGameId, + Title = title, + PublicSlug = slug, + Description = description, + System = system, + Format = format + }, + transaction); + } + catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation) + { + throw new InvalidOperationException("Public slug is already in use.", ex); + } + + // Unpublish before replacing required child links so the deferred validator never sees a + // public card without a session/master. The trigger acquires the same advisory lock. + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + WHERE pg.id = @PortfolioGameId + AND pg.is_public = true + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + if (sessionIds.Length > 0) + { + var validatedSessions = (await conn.QueryAsync( + """ + SELECT s.id + FROM sessions s + WHERE s.id = ANY(@SessionIds) + AND s.group_id = @GroupId + AND s.scheduled_at < now() + """, + new { SessionIds = sessionIds, GroupId = groupId }, + transaction)).ToHashSet(); + + if (validatedSessions.Count != sessionIds.Length) + { + throw new InvalidOperationException("All linked sessions must belong to the same group and be in the past."); + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + SELECT @PortfolioGameId, UNNEST(@SessionIds::uuid[]) + """, + new { PortfolioGameId = portfolioGameId, SessionIds = sessionIds }, + transaction); + } + else + { + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + } + + if (masterPlayerIds.Length > 0) + { + var validatedMasters = (await conn.QueryAsync( + """ + SELECT p.id + FROM players p + JOIN group_managers gm ON gm.player_id = p.id + WHERE p.id = ANY(@PlayerIds) + AND gm.group_id = @GroupId + """, + new { PlayerIds = masterPlayerIds, GroupId = groupId }, + transaction)).ToHashSet(); + + if (validatedMasters.Count != masterPlayerIds.Length) + { + throw new InvalidOperationException("All masters must be managers of the same group."); + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) + SELECT @PortfolioGameId, UNNEST(@PlayerIds::uuid[]) + """, + new { PortfolioGameId = portfolioGameId, PlayerIds = masterPlayerIds }, + transaction); + } + else + { + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + } + + await transaction.CommitAsync(); + } + + public async Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey) + { + if (string.IsNullOrWhiteSpace(storageKey)) + { + throw new InvalidOperationException("Cover storage key must not be empty."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var priorKey = await conn.QuerySingleOrDefaultAsync( + """ + SELECT cover_storage_key + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (priorKey is null) + { + // Could be NULL cover key on the row. Distinguish "missing" from "null cover". + var rowExists = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId + ) + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + if (!rowExists) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + } + + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET cover_storage_key = @StorageKey, + updated_at = now() + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId, StorageKey = storageKey }, + transaction); + + await transaction.CommitAsync(); + return priorKey; + } + + public async Task DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var coverKey = await conn.QuerySingleOrDefaultAsync( + """ + SELECT cover_storage_key + FROM portfolio_games + WHERE id = @PortfolioGameId + AND group_id = @GroupId + FOR UPDATE + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (coverKey is null) + { + // Could be NULL cover key on the row. Distinguish "missing" from "null cover". + var rowExists = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId + ) + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + if (!rowExists) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId", + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + await transaction.CommitAsync(); + return coverKey; + } + + public async Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var row = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (row is null) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + + if (!isPublic) + { + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + await transaction.CommitAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(row.PublicSlug)) + { + throw new InvalidOperationException("Public slug is required before publishing."); + } + if (string.IsNullOrWhiteSpace(row.Description)) + { + throw new InvalidOperationException("Description is required before publishing."); + } + if (string.IsNullOrWhiteSpace(row.CoverStorageKey)) + { + throw new InvalidOperationException("Cover image is required before publishing."); + } + + var sessionCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + if (sessionCount == 0) + { + throw new InvalidOperationException("At least one linked session is required before publishing."); + } + + var futureSessionCount = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM portfolio_game_sessions pgs + JOIN sessions s ON s.id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND s.scheduled_at < now() + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + if (futureSessionCount != sessionCount) + { + throw new InvalidOperationException("Every linked session must already be in the past before publishing."); + } + + var masterCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + if (masterCount == 0) + { + throw new InvalidOperationException("At least one master is required before publishing."); + } + + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = true, + published_at = COALESCE(pg.published_at, now()), + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + await transaction.CommitAsync(); + } + + public async Task ModeratePortfolioReviewAsync( + Guid reviewId, + Guid portfolioGameId, + Guid groupId, + Guid moderatorPlayerId, + string moderationStatus) + { + if (moderationStatus is not "Approved" and not "Rejected" and not "Hidden") + { + throw new InvalidOperationException("Moderation status must be Approved, Rejected, or Hidden."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var updated = await conn.ExecuteAsync( + """ + UPDATE portfolio_game_reviews r + SET moderation_status = @ModerationStatus, + moderated_by_player_id = @ModeratorPlayerId, + moderated_at = now(), + updated_at = now() + FROM portfolio_games pg + WHERE r.id = @ReviewId + AND r.portfolio_game_id = pg.id + AND pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + """, + new + { + ReviewId = reviewId, + PortfolioGameId = portfolioGameId, + GroupId = groupId, + ModeratorPlayerId = moderatorPlayerId, + ModerationStatus = moderationStatus + }, + transaction); + + if (updated == 0) + { + throw new InvalidOperationException("Review not found in the specified portfolio game."); + } + + await transaction.CommitAsync(); + } + + // --- Review submission --- + + public async Task GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) + { + return PortfolioReviewSubmissionState.RequiresAuthentication; + } + + var portfolioGameId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id + FROM portfolio_games pg + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (portfolioGameId is null) + { + return PortfolioReviewSubmissionState.Ineligible; + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.author_player_id = ANY(@PlayerIds) + ) + """, + new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds }); + if (existing) + { + return PortfolioReviewSubmissionState.AlreadySubmitted; + } + + var eligible = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + JOIN session_participants sp ON sp.session_id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND sp.player_id = ANY(@PlayerIds) + AND sp.is_gm = false + AND sp.registration_status = @Active + AND EXISTS ( + SELECT 1 FROM sessions s + WHERE s.id = pgs.session_id + AND s.scheduled_at < now() + ) + ) + """, + new + { + PortfolioGameId = portfolioGameId.Value, + PlayerIds = playerIds, + Active = ParticipantRegistrationStatus.Active + }); + + return eligible + ? PortfolioReviewSubmissionState.Eligible + : PortfolioReviewSubmissionState.Ineligible; + } + + public async Task SubmitPortfolioReviewAsync( + string slug, + string platform, + string externalUserId, + string displayName, + string body) + { + var normalizedBody = PortfolioValidation.NormalizeReviewBody(body); + var normalizedName = (displayName ?? string.Empty).Trim(); + if (normalizedName.Length == 0) + { + throw new InvalidOperationException("Display name is required."); + } + if (normalizedName.Length > 255) + { + throw new InvalidOperationException("Display name is too long."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + var portfolioGameId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id + FROM portfolio_games pg + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (portfolioGameId is null) + { + throw new InvalidOperationException("Public portfolio game not found."); + } + + var eligible = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + JOIN session_participants sp ON sp.session_id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND sp.player_id = ANY(@PlayerIds) + AND sp.is_gm = false + AND sp.registration_status = @Active + AND EXISTS ( + SELECT 1 FROM sessions s + WHERE s.id = pgs.session_id + AND s.scheduled_at < now() + ) + ) + """, + new + { + PortfolioGameId = portfolioGameId.Value, + PlayerIds = playerIds, + Active = ParticipantRegistrationStatus.Active + }); + + if (!eligible) + { + throw new InvalidOperationException("Only past participants of a linked session can submit a review."); + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.author_player_id = ANY(@PlayerIds) + ) + """, + new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds }); + if (existing) + { + throw new InvalidOperationException("You have already submitted a review for this adventure."); + } + + var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + await using var transaction = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_reviews + (portfolio_game_id, author_player_id, author_display_name, body, publication_consent_at, moderation_status) + VALUES + (@PortfolioGameId, @AuthorPlayerId, @AuthorDisplayName, @Body, now(), 'Pending') + ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING + """, + new + { + PortfolioGameId = portfolioGameId.Value, + AuthorPlayerId = effectiveId.Value, + AuthorDisplayName = normalizedName, + Body = normalizedBody + }, + transaction); + + await transaction.CommitAsync(); + } + + // --- Internal helpers --- + + private static async Task ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + return await conn.QuerySingleOrDefaultAsync( + "SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId", + new { Platform = platform, ExternalUserId = externalUserId }); + } + + private static async Task ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var playerId = await ResolvePlayerIdAsync(conn, platform, externalUserId); + if (playerId is null) + { + return null; + } + + var primaryId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT primary_player_id + FROM player_links + WHERE secondary_player_id = @PlayerId + """, + new { PlayerId = playerId.Value }); + + return primaryId ?? playerId; + } + + private static async Task ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + { + return []; + } + + return (await conn.QueryAsync( + """ + SELECT @EffectiveId + UNION + SELECT secondary_player_id + FROM player_links + WHERE primary_player_id = @EffectiveId + """, + new { EffectiveId = effectiveId.Value })).ToArray(); + } + + private PublicPortfolioCard MapToPublicCard(PublicCardRow row) + { + return new PublicPortfolioCard( + row.Slug ?? string.Empty, + row.Title, + string.IsNullOrEmpty(row.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(row.CoverStorageKey), + row.System, + row.Format, + row.CompletedAt); + } + + // --- Internal DTOs (Dapper row shapes) --- + + private sealed record PublicCardRow( + Guid Id, + string? Slug, + string Title, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt); + + private sealed record PublicDetailRow( + Guid Id, + string? Slug, + string Title, + string? Description, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt, + string? ClubName, + string? ClubSlug); + + private sealed record PublicMasterRow(string? Slug, string DisplayName); + + private sealed record PublicReviewRow(string AuthorDisplayName, string Body, DateTime CreatedAt); + + private sealed record PortfolioGameSummaryRow( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + bool IsPublic, + DateTime CompletedAt, + int SessionCount, + int MasterCount, + int PendingReviewCount); + + private sealed record EditorHeaderRow( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + string? Description, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt, + bool IsPublic); + + private sealed record SessionOptionRow(Guid Id, string Title, DateTime ScheduledAt, bool Selected); + + private sealed record MasterOptionRow(Guid PlayerId, string DisplayName, bool Selected); + + private sealed record ModerationReviewRow( + Guid Id, + string AuthorDisplayName, + string Body, + string ModerationStatus, + DateTime CreatedAt); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs new file mode 100644 index 0000000..86121ce --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs @@ -0,0 +1,95 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioServiceSourceTests +{ + [Fact] + public async Task PortfolioService_ShouldExposePortfolioTablesAndPublicationGuards() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + + Assert.Contains("portfolio_games", source, StringComparison.Ordinal); + Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal); + Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal); + Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal); + Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal); + Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal); + Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal); + Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicMasterPortfolioQuery_ShouldNotRequirePublicSchedule() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + var publicMasterQuery = PublicMasterQuerySection(source); + + Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal); + Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPortfolioQuery_ShouldRequirePublicSchedule() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + var publicClubQuery = PublicClubQuerySection(source); + + Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal); + } + + [Fact] + public async Task ShowcaseSessionQuery_ShouldKeepFourHourFutureWindow() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); + var showcaseQuery = ShowcaseQuerySection(source); + + Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal); + } + + private static string PublicMasterQuerySection(string source) + { + var start = source.IndexOf("GetPublicPortfolioGamesForMasterAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetPublicPortfolioGamesForClubAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static string PublicClubQuerySection(string source) + { + var start = source.IndexOf("GetPublicPortfolioGamesForClubAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetPublicPortfolioGameBySlugAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static string ShowcaseQuerySection(string source) + { + var start = source.IndexOf("GetShowcaseSessionsAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetShowcaseSessionAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +}