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); }