diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index d682780..ca40d95 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -73,6 +73,7 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( { var migration = await ReadRepositoryFileAsync( "src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + var normalizedMigration = NormalizeSql(migration); Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal); Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal); @@ -85,6 +86,16 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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); + Assert.Contains("format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE UNIQUE INDEX ux_portfolio_games_public_slug ON portfolio_games (lower(public_slug)) WHERE public_slug IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now()", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters", normalizedMigration, StringComparison.Ordinal); } ``` @@ -167,6 +178,48 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); +CREATE FUNCTION unpublish_portfolio_game_without_required_links() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM 1 + FROM portfolio_games + WHERE id = OLD.portfolio_game_id + FOR UPDATE; + + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = OLD.portfolio_game_id + AND is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + ); + + RETURN OLD; +END; +$$; + +CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete +AFTER DELETE ON portfolio_game_sessions +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); + +CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete +AFTER DELETE ON portfolio_game_masters +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); + 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, @@ -188,10 +241,12 @@ CREATE INDEX ix_portfolio_game_reviews_public WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL; CREATE INDEX ix_portfolio_game_reviews_pending - ON portfolio_game_reviews (created_at) + ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending'; ``` +The delete triggers retain the link-table `ON DELETE CASCADE` behavior. They lock the parent card before checking both required link sets, unpublish the card and refresh `updated_at` when either set becomes empty, preserve the first-publication `published_at`, and become harmless no-ops while the card itself or its owning club is being deleted. + - [ ] **Step 4: Run the migration tests to verify GREEN** Run the Task 1 command again. Expected: PASS. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 162adb4..84cf31f 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -79,6 +79,8 @@ CHECK (NOT is_public OR ( Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. +Database triggers on `portfolio_game_sessions` and `portfolio_game_masters` protect the same invariant after link removal. After a link is deleted, the trigger locks the parent card, checks both required link sets, and sets `is_public = false` with a fresh `updated_at` when either set is empty. It deliberately preserves `published_at` as the timestamp of the first publication. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, the trigger update is a harmless no-op for the disappearing parent row. + ### `portfolio_game_sessions` | Column | Type | Constraints | Description | @@ -125,7 +127,7 @@ UNIQUE (portfolio_game_id, author_player_id) ``` - Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`. -- Partial moderation index on `(created_at)` where `moderation_status = 'Pending'`. +- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`. --- @@ -339,7 +341,7 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, constraints, and indexes. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and the link-removal trigger protection. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. 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 index 1243f1c..c6aeed4 100644 --- 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 @@ -52,6 +52,48 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); +CREATE FUNCTION unpublish_portfolio_game_without_required_links() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM 1 + FROM portfolio_games + WHERE id = OLD.portfolio_game_id + FOR UPDATE; + + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = OLD.portfolio_game_id + AND is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + ); + + RETURN OLD; +END; +$$; + +CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete +AFTER DELETE ON portfolio_game_sessions +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); + +CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete +AFTER DELETE ON portfolio_game_masters +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); + 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, @@ -80,5 +122,5 @@ CREATE INDEX ix_portfolio_game_reviews_public WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL; CREATE INDEX ix_portfolio_game_reviews_pending - ON portfolio_game_reviews (created_at) + ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending'; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 72c9d27..dd74505 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -21,11 +21,21 @@ public sealed class PortfolioMigrationTests Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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,", normalizedMigration, StringComparison.Ordinal); Assert.Contains("moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE UNIQUE INDEX ux_portfolio_games_public_slug ON portfolio_games (lower(public_slug)) WHERE public_slug IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE INDEX ix_portfolio_games_public ON portfolio_games (completed_at DESC) WHERE is_public = true;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_author ON portfolio_game_reviews (author_player_id);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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;", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (created_at) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = OLD.portfolio_game_id AND is_public = true AND (NOT EXISTS (SELECT 1 FROM portfolio_game_sessions WHERE portfolio_game_id = OLD.portfolio_game_id) OR NOT EXISTS (SELECT 1 FROM portfolio_game_masters WHERE portfolio_game_id = OLD.portfolio_game_id));", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); }