Files
GmRelayBot/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs
T

1110 lines
41 KiB
C#

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<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<PublicCardRow>(
"""
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<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<PublicCardRow>(
"""
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<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug)
{
await using var conn = await dataSource.OpenConnectionAsync();
var detail = await conn.QuerySingleOrDefaultAsync<PublicDetailRow>(
"""
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<PublicMasterRow>(
"""
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<PublicReviewRow>(
"""
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<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<PortfolioGameSummaryRow>(
"""
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<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<Guid?>(
"SELECT group_id FROM portfolio_games WHERE id = @PortfolioGameId",
new { PortfolioGameId = portfolioGameId });
}
public async Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var header = await conn.QuerySingleOrDefaultAsync<EditorHeaderRow>(
"""
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<SessionOptionRow>(
"""
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<MasterOptionRow>(
"""
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<ModerationReviewRow>(
"""
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<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<SessionOptionRow>(
"""
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<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<MasterOptionRow>(
"""
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<Guid> 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<Guid>(
"""
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<Guid>();
var masterPlayerIds = update.MasterPlayerIds?.Distinct().ToArray() ?? Array.Empty<Guid>();
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<EditorHeaderRow>(
"""
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<Guid>(
"""
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<Guid>(
"""
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<string?> 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<string?>(
"""
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<bool>(
"""
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<string?> 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<string?>(
"""
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<bool>(
"""
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<EditorHeaderRow>(
"""
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<int>(
"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<int>(
"""
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<int>(
"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<PortfolioReviewSubmissionState> 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<Guid?>(
"""
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<bool>(
"""
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<bool>(
"""
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<Guid?>(
"""
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<bool>(
"""
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<bool>(
"""
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<Guid?> ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
return await conn.QuerySingleOrDefaultAsync<Guid?>(
"SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId",
new { Platform = platform, ExternalUserId = externalUserId });
}
private static async Task<Guid?> 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<Guid?>(
"""
SELECT primary_player_id
FROM player_links
WHERE secondary_player_id = @PlayerId
""",
new { PlayerId = playerId.Value });
return primaryId ?? playerId;
}
private static async Task<Guid[]> ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
{
return [];
}
return (await conn.QueryAsync<Guid>(
"""
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);
}