feat(web): add completed-game portfolio to GM showcase (issue #108) #118
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user