1110 lines
41 KiB
C#
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);
|
|
}
|