fix(data): harden portfolio publication concurrency

This commit is contained in:
2026-06-01 09:46:18 +03:00
parent d591e5ed5a
commit 3c1a98bcc4
11 changed files with 648 additions and 79 deletions
@@ -52,47 +52,64 @@ 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()
CREATE FUNCTION validate_public_portfolio_game_required_links()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
target_portfolio_game_id UUID;
BEGIN
PERFORM 1
FROM portfolio_games
WHERE id = OLD.portfolio_game_id
FOR UPDATE;
IF TG_TABLE_NAME = 'portfolio_games' THEN
target_portfolio_game_id := NEW.id;
ELSE
target_portfolio_game_id := OLD.portfolio_game_id;
END IF;
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
IF EXISTS (
SELECT 1
FROM portfolio_games pg
WHERE pg.id = target_portfolio_game_id
AND pg.is_public = true
AND (
NOT EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = target_portfolio_game_id
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = target_portfolio_game_id
)
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters
WHERE portfolio_game_id = OLD.portfolio_game_id
)
);
) THEN
RAISE EXCEPTION
'published portfolio game % must have at least one linked session and at least one linked master',
target_portfolio_game_id
USING ERRCODE = '23514';
END IF;
RETURN OLD;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete
AFTER DELETE ON portfolio_game_sessions
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete
AFTER DELETE ON portfolio_game_masters
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE TABLE portfolio_game_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -43,6 +43,23 @@ public sealed class DiscordDeleteSessionHandler(
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await connection.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE pgs.portfolio_game_id = pg.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND pg.is_public = true
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
@@ -62,7 +62,21 @@ public sealed class DeleteSessionHandler(
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
}
// 2. Delete session
// 2. Unpublish a linked portfolio card before its required session link cascades away.
await connection.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = @SessionId
AND pg.is_public = true
""",
new { command.SessionId },
transaction);
// 3. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue