fix(data): protect portfolio publication invariant
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user