fix(data): protect portfolio publication invariant

This commit is contained in:
2026-06-01 09:20:27 +03:00
parent 5809a470b9
commit d591e5ed5a
4 changed files with 114 additions and 5 deletions
@@ -73,6 +73,7 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards(
{ {
var migration = await ReadRepositoryFileAsync( var migration = await ReadRepositoryFileAsync(
"src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); "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_games", migration, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE portfolio_game_sessions", 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("publication_consent_at", migration, StringComparison.Ordinal);
Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal); Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal);
Assert.Contains("ix_portfolio_game_reviews_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 CREATE INDEX ix_portfolio_game_masters_player
ON portfolio_game_masters (player_id, portfolio_game_id); 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 ( CREATE TABLE portfolio_game_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, 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; WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_pending 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'; 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** - [ ] **Step 4: Run the migration tests to verify GREEN**
Run the Task 1 command again. Expected: PASS. Run the Task 1 command again. Expected: PASS.
@@ -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. 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` ### `portfolio_game_sessions`
| Column | Type | Constraints | Description | | 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 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 ### 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. - 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. - Existing showcase tests continue to assert the future-session catalog boundary.
@@ -52,6 +52,48 @@ CREATE TABLE portfolio_game_masters (
CREATE INDEX ix_portfolio_game_masters_player CREATE INDEX ix_portfolio_game_masters_player
ON portfolio_game_masters (player_id, portfolio_game_id); 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 ( CREATE TABLE portfolio_game_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, 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; WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_pending 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'; WHERE moderation_status = 'Pending';
@@ -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, 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("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("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_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_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_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_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); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal);
} }