From 67b8aafd97b1060e0aae2d1271a58806cc54fcc6 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 23:21:31 +0300 Subject: [PATCH] feat(data): add completed game portfolio schema --- ..._completed_game_portfolios_and_reviews.sql | 77 +++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 52 +++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql new file mode 100644 index 0000000..9e0df78 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -0,0 +1,77 @@ +-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews. + +CREATE TABLE portfolio_games ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE, + public_slug VARCHAR(160), + title VARCHAR(255) NOT NULL, + description TEXT, + cover_storage_key TEXT, + system VARCHAR(50), + format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')), + completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_public BOOLEAN NOT NULL DEFAULT false, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + NOT is_public + OR ( + public_slug IS NOT NULL + AND description IS NOT NULL + AND cover_storage_key IS NOT NULL + AND published_at IS NOT NULL + ) + ) +); + +CREATE UNIQUE INDEX ux_portfolio_games_public_slug + ON portfolio_games (lower(public_slug)) + WHERE public_slug IS NOT NULL; + +CREATE INDEX ix_portfolio_games_group + ON portfolio_games (group_id, completed_at DESC); + +CREATE INDEX ix_portfolio_games_public + ON portfolio_games (completed_at DESC) + WHERE is_public = true; + +CREATE TABLE portfolio_game_sessions ( + portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + PRIMARY KEY (portfolio_game_id, session_id), + UNIQUE (session_id) +); + +CREATE TABLE portfolio_game_masters ( + portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + PRIMARY KEY (portfolio_game_id, player_id) +); + +CREATE INDEX ix_portfolio_game_masters_player + ON portfolio_game_masters (player_id, portfolio_game_id); + +CREATE TABLE portfolio_game_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, + author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + author_display_name VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + publication_consent_at TIMESTAMPTZ NOT NULL, + moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending' + CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')), + moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL, + moderated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (portfolio_game_id, author_player_id) +); + +CREATE INDEX ix_portfolio_game_reviews_public + ON portfolio_game_reviews (portfolio_game_id, created_at DESC) + WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL; + +CREATE INDEX ix_portfolio_game_reviews_pending + ON portfolio_game_reviews (created_at) + WHERE moderation_status = 'Pending'; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs new file mode 100644 index 0000000..50f911c --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -0,0 +1,52 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioMigrationTests +{ + [Fact] + public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + + Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_reviews", migration, StringComparison.Ordinal); + Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal); + Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal); + Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal); + Assert.Contains("'Pending'", migration, StringComparison.Ordinal); + Assert.Contains("'Approved'", migration, StringComparison.Ordinal); + Assert.Contains("'Rejected'", migration, StringComparison.Ordinal); + Assert.Contains("'Hidden'", migration, StringComparison.Ordinal); + Assert.Contains("publication_consent_at", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_game_reviews_public", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + + Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal); + Assert.DoesNotContain("s3_bucket", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +}