From 3fbff9236c6a9ceac664a1e009a66eea585de5dd Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 22 May 2026 14:29:16 -0700 Subject: [PATCH 1/3] feat(notifications): comment, mention, thread, reaction, fan_club_text_post triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the five comment-related notification triggers that apps' src/tasks/entity_manager/entities/comment.py creates directly from Python during ManageEntity processing. The ETL handlers in go-openaudio (pkg/etl/processors/entity_manager/comment_*.go) already write the source rows — comments, comment_mentions, comment_threads, comment_reactions — but the user-facing notifications had no Go equivalent. handle_comment.sql here previously only updated aggregate_track.comment_count. Closes the largest remaining notification gap on the path to shutting off the Python discovery indexer. New trigger files (one notification type each, all on the same table-per-trigger pattern as handle_comment_remix_contest_update.sql): handle_comment_notification.sql → `comment` Fires on comments INSERT, deferred. Notifies entity owner (track owner / event host / fan-club artist) of a new top-level comment. Skips: self-comment, replies (comment_threads exists), owner- mentioned (comment_mentions for owner — they get comment_mention instead), and comment_notification_settings / muted_users mutes. handle_comment_mention.sql → `comment_mention` Fires on comment_mentions INSERT. Notifies the mentioned user. Skips: self-mention, mention has muted the commenter, owner is mentioned AND owner muted notifications on the entity. handle_comment_thread.sql → `comment_thread` Fires on comment_threads INSERT. Notifies the parent comment author. Skips: self-reply, parent author muted the thread or the replier. handle_comment_reaction.sql → `comment_reaction` Fires on comment_reactions INSERT. Notifies the comment author. Skips: self-react, comment author muted notifications on the comment or the reacter, plus apps' track_owner_mention_mute when the commenter is the entity owner. handle_fan_club_text_post.sql → `fan_club_text_post` Fires on comments INSERT (FanClub entity_type), deferred. Fans out to (followers ∪ artist-coin holders) − {artist}. One row per recipient with specifier=recipient_id (unique constraint on (group_id, specifier) dedupes). Why DEFERRABLE INITIALLY DEFERRED on the comments INSERT triggers: "Top-level" is determined by the absence of comment_threads for this comment_id, and "owner is mentioned" by the presence of a comment_mentions row. Both sibling rows are inserted AFTER the comments row in the same indexer transaction. Same pattern as handle_comment_remix_contest_update.sql. Intentionally deferred (matches apps but not ported here): the karma- based mute check that drops a notification when SUM(follower_count) of users who muted the commenter exceeds a threshold (1.7M prod, 4k dev). Keeps the triggers localized; the threshold lives in apps' config not the DB. If notification noise becomes a problem we can fold it in. Notification payload shapes match apps verbatim (specifier, group_id, data) so existing notification readers / clients keep working. Schema dump regeneration follows in a separate commit (cf. 4da78ab for the handle_comment_remix_contest_update precedent). Tests (api/v1_comment_notifications_test.go — 9 tests, all DB-backed): - TestCommentNotification_NotifiesTrackOwner — happy path: track owner receives `comment` with the correct group_id and payload - TestCommentNotification_SkipsSelfComment — self-comment no-op - TestCommentNotification_SkipsReply — reply inserted with comment_threads in the same tx → deferred trigger correctly skips - TestCommentMention_NotifiesMentionedUser — mentioned user gets `comment_mention` with entity_user_id / comment_user_id - TestCommentMention_SkipsWhenMentionedMutedCommenter — muted_users gates - TestCommentThread_NotifiesParentAuthor — parent author gets `comment_thread`, specifier = reply comment_id - TestCommentReaction_NotifiesCommentAuthor — author gets `comment_reaction`, specifier = reacter user_id - TestFanClubTextPost_FansOutToFollowersAndCoinHolders — recipients = follower ∪ coin holder, deduped via UNION; artist excluded - TestFanClubTextPost_SkipsFanComments — only artist's own top-level posts trigger fan-out Co-Authored-By: Claude Opus 4.7 --- api/v1_comment_notifications_test.go | 538 ++++++++++++++++++ ddl/functions/handle_comment_mention.sql | 147 +++++ ddl/functions/handle_comment_notification.sql | 151 +++++ ddl/functions/handle_comment_reaction.sql | 151 +++++ ddl/functions/handle_comment_thread.sql | 134 +++++ ddl/functions/handle_fan_club_text_post.sql | 111 ++++ 6 files changed, 1232 insertions(+) create mode 100644 api/v1_comment_notifications_test.go create mode 100644 ddl/functions/handle_comment_mention.sql create mode 100644 ddl/functions/handle_comment_notification.sql create mode 100644 ddl/functions/handle_comment_reaction.sql create mode 100644 ddl/functions/handle_comment_thread.sql create mode 100644 ddl/functions/handle_fan_club_text_post.sql diff --git a/api/v1_comment_notifications_test.go b/api/v1_comment_notifications_test.go new file mode 100644 index 00000000..06129a45 --- /dev/null +++ b/api/v1_comment_notifications_test.go @@ -0,0 +1,538 @@ +package api + +import ( + "context" + "encoding/json" + "testing" + "time" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Exercises the comment notification triggers ported from apps' +// src/tasks/entity_manager/entities/comment.py: +// +// handle_comment_notification.sql → comment +// handle_comment_mention.sql → comment_mention +// handle_comment_thread.sql → comment_thread +// handle_comment_reaction.sql → comment_reaction +// handle_fan_club_text_post.sql → fan_club_text_post + +func TestCommentNotification_NotifiesTrackOwner(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + require.NotNil(t, app.writePool, "test requires write pool") + + const ( + ownerId = 8101 + fanId = 8102 + trackId = 8201 + commentId = 8301 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "cn-blk-100", "parenthash": nil, "number": 100}}, + "users": { + {"user_id": ownerId, "handle": "cn_owner"}, + {"user_id": fanId, "handle": "cn_fan"}, + }, + "tracks": {{ + "track_id": trackId, + "owner_id": ownerId, + "title": "Track A", + "created_at": now, + "updated_at": now, + }}, + }) + + // Fan leaves a top-level comment on owner's track. + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comments ( + comment_id, user_id, entity_id, entity_type, + text, is_delete, is_visible, is_edited, + created_at, updated_at, + txhash, blockhash, blocknumber + ) VALUES ($1, $2, $3, 'Track', 'nice track', false, true, false, + $4, $4, 'tx-cn-1', 'cn-blk-100', 100) + `, commentId, fanId, trackId, now) + require.NoError(t, err) + + type row struct { + UserIDs []int32 + GroupID string + Data []byte + } + var r row + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT user_ids, group_id, data + FROM notification + WHERE type = 'comment' + AND group_id = $1 + `, "comment:8201:type:Track").Scan(&r.UserIDs, &r.GroupID, &r.Data)) + assert.Equal(t, []int32{ownerId}, r.UserIDs) + var data map[string]any + require.NoError(t, json.Unmarshal(r.Data, &data)) + assert.Equal(t, "Track", data["type"]) + assert.EqualValues(t, trackId, data["entity_id"]) + assert.EqualValues(t, fanId, data["comment_user_id"]) + assert.EqualValues(t, commentId, data["comment_id"]) +} + +func TestCommentNotification_SkipsSelfComment(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + ownerId = 8401 + trackId = 8501 + commentId = 8601 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "cn-blk-150", "parenthash": nil, "number": 150}}, + "users": {{"user_id": ownerId, "handle": "cn_self"}}, + "tracks": {{ + "track_id": trackId, "owner_id": ownerId, "title": "Own", + "created_at": now, "updated_at": now, + }}, + }) + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comments (comment_id, user_id, entity_id, entity_type, + text, is_delete, is_visible, is_edited, created_at, updated_at, + txhash, blockhash, blocknumber) + VALUES ($1, $2, $3, 'Track', 'self', false, true, false, + $4, $4, 'tx-cn-2', 'cn-blk-150', 150) + `, commentId, ownerId, trackId, now) + require.NoError(t, err) + + var n int + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT count(*) FROM notification WHERE type = 'comment' + `).Scan(&n)) + assert.Equal(t, 0, n, "no self-comment notification") +} + +func TestCommentNotification_SkipsReply(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + ownerId = 8701 + fanId = 8702 + trackId = 8801 + parentCommentId = 8901 + replyCommentId = 8902 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": { + {"blockhash": "cn-blk-200", "parenthash": nil, "number": 200}, + {"blockhash": "cn-blk-201", "parenthash": "cn-blk-200", "number": 201}, + }, + "users": { + {"user_id": ownerId, "handle": "cnr_owner"}, + {"user_id": fanId, "handle": "cnr_fan"}, + }, + "tracks": {{ + "track_id": trackId, "owner_id": ownerId, "title": "T", + "created_at": now, "updated_at": now, + }}, + "comments": {{ + "comment_id": parentCommentId, "user_id": fanId, + "entity_id": trackId, "entity_type": "Track", + "text": "first", "is_delete": false, "is_visible": true, + "created_at": now, "updated_at": now, + "txhash": "tx-parent", "blockhash": "cn-blk-200", "blocknumber": 200, + }}, + }) + + // The reply must be inserted alongside its comment_threads row in the + // same transaction so the deferred trigger correctly skips it. + tx, err := app.writePool.Begin(ctx) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + INSERT INTO comments (comment_id, user_id, entity_id, entity_type, + text, is_delete, is_visible, is_edited, created_at, updated_at, + txhash, blockhash, blocknumber) + VALUES ($1, $2, $3, 'Track', 'reply', false, true, false, + $4, $4, 'tx-reply', 'cn-blk-201', 201) + `, replyCommentId, fanId, trackId, now) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + INSERT INTO comment_threads (parent_comment_id, comment_id) + VALUES ($1, $2) + `, parentCommentId, replyCommentId) + require.NoError(t, err) + require.NoError(t, tx.Commit(ctx)) + + // Only the parent comment should produce a `comment` notification. + var count int + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT count(*) FROM notification + WHERE type = 'comment' AND group_id = 'comment:8801:type:Track' + `).Scan(&count)) + assert.Equal(t, 1, count, "reply should not produce a second comment notification") +} + +func TestCommentMention_NotifiesMentionedUser(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + ownerId = 9001 + fanId = 9002 + mentionId = 9003 + trackId = 9101 + commentId = 9201 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "cm-blk-100", "parenthash": nil, "number": 100}}, + "users": { + {"user_id": ownerId, "handle": "cm_owner"}, + {"user_id": fanId, "handle": "cm_fan"}, + {"user_id": mentionId, "handle": "cm_mentioned"}, + }, + "tracks": {{ + "track_id": trackId, "owner_id": ownerId, "title": "T", + "created_at": now, "updated_at": now, + }}, + "comments": {{ + "comment_id": commentId, "user_id": fanId, + "entity_id": trackId, "entity_type": "Track", + "text": "@cm_mentioned check this out", + "is_delete": false, "is_visible": true, + "created_at": now, "updated_at": now, + "txhash": "tx-cm", "blockhash": "cm-blk-100", "blocknumber": 100, + }}, + }) + + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comment_mentions (comment_id, user_id, is_delete, + created_at, updated_at, txhash, blockhash, blocknumber) + VALUES ($1, $2, false, $3, $3, 'tx-cm', 'cm-blk-100', 100) + `, commentId, mentionId, now) + require.NoError(t, err) + + type row struct { + UserIDs []int32 + GroupID string + Data []byte + } + var r row + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT user_ids, group_id, data + FROM notification + WHERE type = 'comment_mention' + AND group_id = $1 + `, "comment_mention:9201").Scan(&r.UserIDs, &r.GroupID, &r.Data)) + assert.Equal(t, []int32{mentionId}, r.UserIDs) + var data map[string]any + require.NoError(t, json.Unmarshal(r.Data, &data)) + assert.EqualValues(t, fanId, data["comment_user_id"]) + assert.EqualValues(t, ownerId, data["entity_user_id"]) + assert.EqualValues(t, trackId, data["entity_id"]) +} + +func TestCommentMention_SkipsWhenMentionedMutedCommenter(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + ownerId = 9301 + fanId = 9302 + mentionId = 9303 + trackId = 9401 + commentId = 9501 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "cm-blk-300", "parenthash": nil, "number": 300}}, + "users": { + {"user_id": ownerId, "handle": "cmm_owner"}, + {"user_id": fanId, "handle": "cmm_fan"}, + {"user_id": mentionId, "handle": "cmm_mentioned"}, + }, + "tracks": {{ + "track_id": trackId, "owner_id": ownerId, "title": "T", + "created_at": now, "updated_at": now, + }}, + "comments": {{ + "comment_id": commentId, "user_id": fanId, + "entity_id": trackId, "entity_type": "Track", + "text": "@cmm_mentioned hi", "is_delete": false, "is_visible": true, + "created_at": now, "updated_at": now, + "txhash": "tx-cmm", "blockhash": "cm-blk-300", "blocknumber": 300, + }}, + // Mentioned user has muted the commenter. + "muted_users": {{ + "muted_user_id": fanId, "user_id": mentionId, + "is_delete": false, + "created_at": now, "updated_at": now, + "txhash": "seed-mute", "blockhash": "cm-blk-300", "blocknumber": 300, + }}, + }) + + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comment_mentions (comment_id, user_id, is_delete, + created_at, updated_at, txhash, blockhash, blocknumber) + VALUES ($1, $2, false, $3, $3, 'tx-cmm', 'cm-blk-300', 300) + `, commentId, mentionId, now) + require.NoError(t, err) + + var n int + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT count(*) FROM notification WHERE type = 'comment_mention' + `).Scan(&n)) + assert.Equal(t, 0, n, "mention notification suppressed by muted_users") +} + +func TestCommentThread_NotifiesParentAuthor(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + ownerId = 9601 + parentUserId = 9602 + replyUserId = 9603 + trackId = 9701 + parentCommentId = 9801 + replyCommentId = 9802 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": { + {"blockhash": "ct-blk-300", "parenthash": nil, "number": 300}, + {"blockhash": "ct-blk-301", "parenthash": "ct-blk-300", "number": 301}, + }, + "users": { + {"user_id": ownerId, "handle": "ct_owner"}, + {"user_id": parentUserId, "handle": "ct_parent"}, + {"user_id": replyUserId, "handle": "ct_reply"}, + }, + "tracks": {{ + "track_id": trackId, "owner_id": ownerId, "title": "T", + "created_at": now, "updated_at": now, + }}, + "comments": { + { + "comment_id": parentCommentId, "user_id": parentUserId, + "entity_id": trackId, "entity_type": "Track", + "text": "parent", "is_delete": false, "is_visible": true, + "created_at": now, "updated_at": now, + "txhash": "tx-ct-p", "blockhash": "ct-blk-300", "blocknumber": 300, + }, + { + "comment_id": replyCommentId, "user_id": replyUserId, + "entity_id": trackId, "entity_type": "Track", + "text": "reply", "is_delete": false, "is_visible": true, + "created_at": now, "updated_at": now, + "txhash": "tx-ct-r", "blockhash": "ct-blk-301", "blocknumber": 301, + }, + }, + }) + + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comment_threads (parent_comment_id, comment_id) + VALUES ($1, $2) + `, parentCommentId, replyCommentId) + require.NoError(t, err) + + type row struct { + UserIDs []int32 + Data []byte + } + var r row + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT user_ids, data + FROM notification + WHERE type = 'comment_thread' + AND group_id = $1 + AND specifier = $2 + `, "comment_thread:9801", "9802").Scan(&r.UserIDs, &r.Data)) + assert.Equal(t, []int32{parentUserId}, r.UserIDs) + var data map[string]any + require.NoError(t, json.Unmarshal(r.Data, &data)) + assert.EqualValues(t, replyUserId, data["comment_user_id"]) + assert.EqualValues(t, replyCommentId, data["comment_id"]) +} + +func TestCommentReaction_NotifiesCommentAuthor(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + ownerId = 10001 + authorId = 10002 + reacterId = 10003 + trackId = 10101 + commentId = 10201 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "cr-blk-100", "parenthash": nil, "number": 100}}, + "users": { + {"user_id": ownerId, "handle": "cr_owner"}, + {"user_id": authorId, "handle": "cr_author"}, + {"user_id": reacterId, "handle": "cr_reacter"}, + }, + "tracks": {{ + "track_id": trackId, "owner_id": ownerId, "title": "T", + "created_at": now, "updated_at": now, + }}, + "comments": {{ + "comment_id": commentId, "user_id": authorId, + "entity_id": trackId, "entity_type": "Track", + "text": "nice", "is_delete": false, "is_visible": true, + "created_at": now, "updated_at": now, + "txhash": "tx-cr-c", "blockhash": "cr-blk-100", "blocknumber": 100, + }}, + }) + + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comment_reactions (comment_id, user_id, is_delete, + created_at, updated_at, txhash, blockhash, blocknumber) + VALUES ($1, $2, false, $3, $3, 'tx-cr-r', 'cr-blk-100', 100) + `, commentId, reacterId, now) + require.NoError(t, err) + + type row struct { + UserIDs []int32 + GroupID string + Specifier string + Data []byte + } + var r row + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT user_ids, group_id, specifier, data + FROM notification + WHERE type = 'comment_reaction' + AND group_id = $1 + `, "comment_reaction:10201").Scan(&r.UserIDs, &r.GroupID, &r.Specifier, &r.Data)) + assert.Equal(t, []int32{authorId}, r.UserIDs) + assert.Equal(t, "10003", r.Specifier, "specifier is the reacter user_id") + var data map[string]any + require.NoError(t, json.Unmarshal(r.Data, &data)) + assert.EqualValues(t, reacterId, data["reacter_user_id"]) + assert.EqualValues(t, commentId, data["comment_id"]) + assert.EqualValues(t, ownerId, data["entity_user_id"]) +} + +func TestFanClubTextPost_FansOutToFollowersAndCoinHolders(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + artistId = 11001 + followerId = 11002 + coinHolderId = 11003 + bothId = 11004 // both follower and coin holder — should still get only one row + strangerId = 11005 // neither — should not get notified + commentId = 11101 + ) + now := time.Now().UTC() + mint := "MintAlpha111" + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "fc-blk-100", "parenthash": nil, "number": 100}}, + "users": { + {"user_id": artistId, "handle": "fc_artist"}, + {"user_id": followerId, "handle": "fc_follower"}, + {"user_id": coinHolderId, "handle": "fc_coin"}, + {"user_id": bothId, "handle": "fc_both"}, + {"user_id": strangerId, "handle": "fc_stranger"}, + }, + "follows": { + {"follower_user_id": followerId, "followee_user_id": artistId, + "is_current": true, "is_delete": false, "created_at": now, "blocknumber": 100, "blockhash": "fc-blk-100"}, + {"follower_user_id": bothId, "followee_user_id": artistId, + "is_current": true, "is_delete": false, "created_at": now, "blocknumber": 100, "blockhash": "fc-blk-100"}, + }, + "artist_coins": {{ + "mint": mint, "ticker": "FCAR", "user_id": artistId, + "decimals": 6, "name": "Fc Artist Coin", + }}, + "sol_user_balances": { + {"user_id": coinHolderId, "mint": mint, "balance": 100, "created_at": now, "updated_at": now}, + {"user_id": bothId, "mint": mint, "balance": 50, "created_at": now, "updated_at": now}, + }, + }) + + // Artist posts a top-level text update on their fan club. + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comments (comment_id, user_id, entity_id, entity_type, + text, is_delete, is_visible, is_edited, is_members_only, + created_at, updated_at, txhash, blockhash, blocknumber) + VALUES ($1, $2, $2, 'FanClub', 'studio update!', false, true, false, true, + $3, $3, 'tx-fc', 'fc-blk-100', 100) + `, commentId, artistId, now) + require.NoError(t, err) + + // followerId, coinHolderId, and bothId should all be notified once. artistId never. + rows, err := app.writePool.Query(ctx, ` + SELECT user_ids, specifier + FROM notification + WHERE type = 'fan_club_text_post' + AND group_id = $1 + ORDER BY specifier + `, "fan_club_text_post:11101:user:11001") + require.NoError(t, err) + defer rows.Close() + + recipients := map[int32]bool{} + for rows.Next() { + var userIDs []int32 + var specifier string + require.NoError(t, rows.Scan(&userIDs, &specifier)) + require.Len(t, userIDs, 1) + recipients[userIDs[0]] = true + } + require.NoError(t, rows.Err()) + + assert.True(t, recipients[followerId], "follower must be notified") + assert.True(t, recipients[coinHolderId], "coin holder must be notified") + assert.True(t, recipients[bothId], "user who is both follower and coin holder gets exactly one notification (UNION)") + assert.False(t, recipients[strangerId], "stranger must not be notified") + assert.False(t, recipients[artistId], "artist (post author) must not be notified") + assert.Len(t, recipients, 3, "exactly 3 unique recipients") +} + +func TestFanClubTextPost_SkipsFanComments(t *testing.T) { + app := emptyTestApp(t) + ctx := context.Background() + + const ( + artistId = 11201 + fanId = 11202 + commentId = 11301 + ) + now := time.Now().UTC() + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "blocks": {{"blockhash": "fc-blk-200", "parenthash": nil, "number": 200}}, + "users": { + {"user_id": artistId, "handle": "fc_artist2"}, + {"user_id": fanId, "handle": "fc_fan"}, + }, + }) + + // A fan (not the artist) posts a top-level comment on the artist's + // fan club. Should NOT fan out — fan_club_text_post is only for the + // artist's own posts. The `comment` notification (to the artist) + // fires instead, but we're only checking fan_club_text_post here. + _, err := app.writePool.Exec(ctx, ` + INSERT INTO comments (comment_id, user_id, entity_id, entity_type, + text, is_delete, is_visible, is_edited, is_members_only, + created_at, updated_at, txhash, blockhash, blocknumber) + VALUES ($1, $2, $3, 'FanClub', 'love your work', false, true, false, true, + $4, $4, 'tx-fc-2', 'fc-blk-200', 200) + `, commentId, fanId, artistId, now) + require.NoError(t, err) + + var n int + require.NoError(t, app.writePool.QueryRow(ctx, ` + SELECT count(*) FROM notification WHERE type = 'fan_club_text_post' + `).Scan(&n)) + assert.Equal(t, 0, n, "fan posts do not trigger fan_club_text_post") +} diff --git a/ddl/functions/handle_comment_mention.sql b/ddl/functions/handle_comment_mention.sql new file mode 100644 index 00000000..2e7daca1 --- /dev/null +++ b/ddl/functions/handle_comment_mention.sql @@ -0,0 +1,147 @@ +-- handle_comment_mention +-- +-- Emits a `comment_mention` notification to a mentioned user when a +-- comment_mentions row is inserted (or undeleted). Mirrors apps' +-- src/tasks/entity_manager/entities/comment.py mention notification +-- block (notification type "comment_mention" with group_id +-- "comment_mention:"). +-- +-- Why this fires on comment_mentions (not comments): +-- The mention rows are written AFTER the comments row in the same +-- indexer transaction (etl/processors/entity_manager/comment_create.go). +-- Hooking the child table lets a plain AFTER INSERT trigger see +-- everything it needs without DEFERRED gymnastics — the comments row +-- already exists by the time comment_mentions is inserted. +-- +-- Skips (mirror apps): +-- - mention == commenter (self-mention) +-- - mentioned user has muted the commenter (muted_users) +-- - if mention == entity owner AND owner has notifications off for +-- this entity, skip — the entity owner already opted out +-- +-- Deferred (intentional): apps also drops mentions when the commenter is +-- karma-muted (1.7M-follower-aggregate threshold across the muting +-- users). Not ported here for the same reason as +-- handle_comment_notification.sql — see header there. +create or replace function handle_comment_mention() returns trigger as $$ +declare + c_row record; + entity_user_id int; + data_entity_ref int; + is_self_mention boolean; + mention_muted boolean; + owner_mute boolean; + is_owner_mention boolean; +begin + if new.is_delete then + return null; + end if; + + -- Fetch the parent comment for entity context + author. + select user_id, entity_type, entity_id, blocknumber, created_at, is_delete, is_visible + into c_row + from comments + where comment_id = new.comment_id + limit 1; + if not found or c_row.is_delete or not c_row.is_visible then + return null; + end if; + + -- Self-mention is a no-op. + if new.user_id = c_row.user_id then + return null; + end if; + + -- Resolve entity owner — used for the "owner has notifications off" + -- gate when the mention IS the owner. + if c_row.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = c_row.entity_id + and t.is_current = true + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = c_row.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'FanClub' then + entity_user_id := c_row.entity_id; + data_entity_ref := c_row.entity_id; + else + return null; + end if; + + is_owner_mention := (entity_user_id is not null and new.user_id = entity_user_id); + + -- Mentioned user has muted the commenter — skip. + select exists ( + select 1 from muted_users mu + where mu.user_id = new.user_id + and mu.muted_user_id = c_row.user_id + and mu.is_delete = false + ) into mention_muted; + if mention_muted then + return null; + end if; + + -- If the mention is the entity owner AND the owner muted notifications + -- on this entity, skip — matches apps' track_owner_mention_mute logic. + if is_owner_mention then + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = entity_user_id + and cns.entity_type = c_row.entity_type + and cns.entity_id = data_entity_ref + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = entity_user_id + and mu.muted_user_id = c_row.user_id + and mu.is_delete = false + ) into owner_mute; + if owner_mute then + return null; + end if; + end if; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + c_row.blocknumber, + ARRAY[new.user_id], + c_row.created_at, + 'comment_mention', + new.user_id::text, + 'comment_mention:' || new.comment_id, + jsonb_build_object( + 'type', c_row.entity_type, + 'entity_id', data_entity_ref, + 'entity_user_id', entity_user_id, + 'comment_user_id', c_row.user_id, + 'comment_id', new.comment_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$ language plpgsql; + + +do $$ begin + create trigger on_comment_mention + after insert on comment_mentions + for each row execute procedure handle_comment_mention(); +exception + when others then null; +end $$; diff --git a/ddl/functions/handle_comment_notification.sql b/ddl/functions/handle_comment_notification.sql new file mode 100644 index 00000000..d6581d86 --- /dev/null +++ b/ddl/functions/handle_comment_notification.sql @@ -0,0 +1,151 @@ +-- handle_comment_notification +-- +-- Emits a `comment` notification to the entity owner (track owner / event +-- host / fan-club artist) when someone leaves a top-level comment on +-- their entity. +-- +-- Sibling of: +-- handle_comment.sql (aggregate_track counts only) +-- handle_comment_remix_contest_update.sql (Event subscriber fan-out) +-- handle_fan_club_text_post.sql (FanClub follower fan-out) +-- +-- Mirrors apps' src/tasks/entity_manager/entities/comment.py top-level +-- `comment` Notification block (notification type "comment" with group_id +-- "comment::type:"). +-- +-- Why DEFERRABLE INITIALLY DEFERRED: +-- "Top-level" means no comment_threads row for this comment_id, and +-- "owner is mentioned" means a comment_mentions row exists with the +-- owner's user_id. Both of those sibling rows are inserted AFTER the +-- comments row in the same indexer transaction. A non-deferred trigger +-- would misclassify replies as top-level and miss owner-mention skips. +-- Same pattern as handle_comment_remix_contest_update.sql. +-- +-- Deferred features (intentional): apps also checks a karma-based mute +-- where a commenter's muters' aggregate follower_count must be < a +-- threshold (default 1.7M prod, 4k dev). Not ported here — keeps the +-- trigger localized and the threshold lives in apps' config not the DB. +-- If noise becomes a problem we can fold it into a follow-up. +create or replace function handle_comment_notification() returns trigger as $$ +declare + entity_user_id int; + data_entity_ref int; + group_id_str text; + is_reply boolean; + owner_mentioned boolean; + owner_mute boolean; +begin + if new.is_delete or not new.is_visible then + return null; + end if; + + -- Resolve recipient (entity_user_id) + data.entity_id by entity_type. + if new.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = new.entity_id + and t.is_current = true + limit 1; + data_entity_ref := new.entity_id; + elsif new.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = new.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := new.entity_id; + elsif new.entity_type = 'FanClub' then + -- For FanClub, entity_id IS the artist's user_id. + entity_user_id := new.entity_id; + data_entity_ref := new.entity_id; + else + return null; + end if; + + if entity_user_id is null then + return null; + end if; + + -- Skip self-comment. + if new.user_id = entity_user_id then + return null; + end if; + + -- Skip replies (they emit comment_thread instead, to the parent + -- comment author). Deferred so comment_threads is visible. + select exists ( + select 1 from comment_threads where comment_id = new.comment_id + ) into is_reply; + if is_reply then + return null; + end if; + + -- Skip if owner is mentioned in this comment (they get comment_mention + -- instead, also more specific). Deferred so comment_mentions is visible. + select exists ( + select 1 from comment_mentions + where comment_id = new.comment_id + and user_id = entity_user_id + and is_delete = false + ) into owner_mentioned; + if owner_mentioned then + return null; + end if; + + -- Skip if owner muted notifications on this entity (CommentNotificationSetting + -- with is_muted=true) OR muted this commenter (MutedUser). + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = entity_user_id + and cns.entity_type = new.entity_type + and cns.entity_id = data_entity_ref + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = entity_user_id + and mu.muted_user_id = new.user_id + and mu.is_delete = false + ) into owner_mute; + if owner_mute then + return null; + end if; + + group_id_str := 'comment:' || data_entity_ref || ':type:' || new.entity_type; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + new.blocknumber, + ARRAY[entity_user_id], + new.created_at, + 'comment', + new.comment_id::text, + group_id_str, + jsonb_build_object( + 'type', new.entity_type, + 'entity_id', data_entity_ref, + 'comment_user_id', new.user_id, + 'comment_id', new.comment_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$ language plpgsql; + + +do $$ begin + create constraint trigger on_comment_notification + after insert on comments + deferrable initially deferred + for each row execute procedure handle_comment_notification(); +exception + when others then null; +end $$; diff --git a/ddl/functions/handle_comment_reaction.sql b/ddl/functions/handle_comment_reaction.sql new file mode 100644 index 00000000..417a8af5 --- /dev/null +++ b/ddl/functions/handle_comment_reaction.sql @@ -0,0 +1,151 @@ +-- handle_comment_reaction +-- +-- Emits a `comment_reaction` notification to the comment's author when +-- someone reacts to their comment. Mirrors apps' +-- src/tasks/entity_manager/entities/comment.py react_comment block +-- (notification type "comment_reaction" with group_id +-- "comment_reaction:", specifier = reacter user_id). +-- +-- Fires on comment_reactions INSERT, which the indexer writes via +-- etl/processors/entity_manager/comment_react.go. +-- +-- Note: NOT to be confused with handle_reaction.sql, which fires on the +-- `reactions` table for TIP reactions only. Comment reactions live in a +-- separate `comment_reactions` table with a different shape. +-- +-- Skips (mirror apps): +-- - reacter == comment author (self-react) +-- - comment author muted notifications on this comment +-- (comment_notification_settings) OR muted the reacter (muted_users) +-- - if comment author IS the entity owner AND owner has notifications +-- off for the entity, skip — matches apps' track_owner_mention_mute +-- +-- Deferred (intentional): karma mute. See handle_comment_notification.sql. +create or replace function handle_comment_reaction() returns trigger as $$ +declare + c_row record; + entity_user_id int; + data_entity_ref int; + comment_owner_mute boolean; + owner_mute_extra boolean; +begin + if new.is_delete then + return null; + end if; + + -- The comment being reacted to. Use the stored entity_type from the + -- comments row (apps notes clients have sometimes shipped wrong values + -- in the reaction's metadata). + select user_id, entity_type, entity_id, is_delete, is_visible + into c_row + from comments + where comment_id = new.comment_id + limit 1; + if not found or c_row.is_delete or not c_row.is_visible then + return null; + end if; + + -- Self-react is a no-op. + if new.user_id = c_row.user_id then + return null; + end if; + + -- Resolve entity context for the notification payload. + if c_row.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = c_row.entity_id + and t.is_current = true + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = c_row.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'FanClub' then + entity_user_id := c_row.entity_id; + data_entity_ref := c_row.entity_id; + else + entity_user_id := null; + data_entity_ref := c_row.entity_id; + end if; + + -- Comment author muted notifications on this comment OR this reacter. + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = c_row.user_id + and cns.entity_type = 'Comment' + and cns.entity_id = new.comment_id + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = c_row.user_id + and mu.muted_user_id = new.user_id + and mu.is_delete = false + ) into comment_owner_mute; + if comment_owner_mute then + return null; + end if; + + -- Apps' track_owner_mention_mute: if commenter is the entity owner + -- AND owner has notifications off on the entity, drop the reaction + -- notification too (their muted state shouldn't be circumvented by + -- a reaction notification). + if entity_user_id is not null and c_row.user_id = entity_user_id then + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = entity_user_id + and cns.entity_type = c_row.entity_type + and cns.entity_id = data_entity_ref + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = entity_user_id + and mu.muted_user_id = new.user_id + and mu.is_delete = false + ) into owner_mute_extra; + if owner_mute_extra then + return null; + end if; + end if; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + new.blocknumber, + ARRAY[c_row.user_id], + new.created_at, + 'comment_reaction', + new.user_id::text, + 'comment_reaction:' || new.comment_id, + jsonb_build_object( + 'type', c_row.entity_type, + 'entity_id', data_entity_ref, + 'entity_user_id', entity_user_id, + 'comment_id', new.comment_id, + 'reacter_user_id', new.user_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$ language plpgsql; + + +do $$ begin + create trigger on_comment_reaction + after insert on comment_reactions + for each row execute procedure handle_comment_reaction(); +exception + when others then null; +end $$; diff --git a/ddl/functions/handle_comment_thread.sql b/ddl/functions/handle_comment_thread.sql new file mode 100644 index 00000000..bb1a3b76 --- /dev/null +++ b/ddl/functions/handle_comment_thread.sql @@ -0,0 +1,134 @@ +-- handle_comment_thread +-- +-- Emits a `comment_thread` notification to the parent comment's author +-- when someone replies to their comment. Mirrors apps' +-- src/tasks/entity_manager/entities/comment.py thread notification +-- block (notification type "comment_thread" with group_id +-- "comment_thread:", specifier = reply comment_id). +-- +-- Fires on comment_threads INSERT, which the indexer writes for every +-- reply (etl/processors/entity_manager/comment_create.go) after the +-- comments row exists in the same transaction. +-- +-- Skips (mirror apps): +-- - parent author == reply author (self-reply) +-- - parent author muted notifications on the parent comment +-- (comment_notification_settings) +-- - parent author muted the reply author (muted_users) +-- +-- Deferred (intentional): apps also drops the notification when the +-- reply author is karma-muted. See handle_comment_notification.sql +-- header for rationale. +create or replace function handle_comment_thread() returns trigger as $$ +declare + reply_row record; + parent_row record; + entity_user_id int; + data_entity_ref int; + parent_mute boolean; +begin + -- The reply. + select user_id, blocknumber, created_at, is_delete, is_visible + into reply_row + from comments + where comment_id = new.comment_id + limit 1; + if not found or reply_row.is_delete or not reply_row.is_visible then + return null; + end if; + + -- The parent — used for both recipient and the entity context the + -- notification payload includes. + select user_id, entity_type, entity_id + into parent_row + from comments + where comment_id = new.parent_comment_id + limit 1; + if not found then + return null; + end if; + + -- Self-reply is a no-op. + if reply_row.user_id = parent_row.user_id then + return null; + end if; + + -- Resolve the entity owner for the notification payload (matches the + -- entity-type switch in apps' comment.py). + if parent_row.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = parent_row.entity_id + and t.is_current = true + limit 1; + data_entity_ref := parent_row.entity_id; + elsif parent_row.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = parent_row.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := parent_row.entity_id; + elsif parent_row.entity_type = 'FanClub' then + entity_user_id := parent_row.entity_id; + data_entity_ref := parent_row.entity_id; + else + -- Unknown entity_type — emit without owner context rather than skip. + entity_user_id := null; + data_entity_ref := parent_row.entity_id; + end if; + + -- Parent author muted this thread or this user. + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = parent_row.user_id + and cns.entity_type = 'Comment' + and cns.entity_id = new.parent_comment_id + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = parent_row.user_id + and mu.muted_user_id = reply_row.user_id + and mu.is_delete = false + ) into parent_mute; + if parent_mute then + return null; + end if; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + reply_row.blocknumber, + ARRAY[parent_row.user_id], + reply_row.created_at, + 'comment_thread', + new.comment_id::text, + 'comment_thread:' || new.parent_comment_id, + jsonb_build_object( + 'type', parent_row.entity_type, + 'entity_id', data_entity_ref, + 'entity_user_id', entity_user_id, + 'comment_user_id', reply_row.user_id, + 'comment_id', new.comment_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$ language plpgsql; + + +do $$ begin + create trigger on_comment_thread + after insert on comment_threads + for each row execute procedure handle_comment_thread(); +exception + when others then null; +end $$; diff --git a/ddl/functions/handle_fan_club_text_post.sql b/ddl/functions/handle_fan_club_text_post.sql new file mode 100644 index 00000000..92fdd390 --- /dev/null +++ b/ddl/functions/handle_fan_club_text_post.sql @@ -0,0 +1,111 @@ +-- handle_fan_club_text_post +-- +-- Emits a `fan_club_text_post` notification to followers + artist-coin +-- holders when a fan-club artist posts a top-level "text update" on +-- their fan club. Mirrors apps' +-- src/tasks/entity_manager/entities/comment.py FanClub block. +-- +-- Sibling of: +-- handle_comment.sql (aggregates) +-- handle_comment_notification.sql (entity-owner notif) +-- handle_comment_remix_contest_update.sql (Event-host fan-out) +-- +-- Fan-club entity_id IS the artist's user_id (apps uses entity_id as +-- the artist's user identifier for FanClub-typed comments). The post +-- author MUST be the artist themselves — a fan's comment on the fan +-- club is just a regular comment, not a "text post". +-- +-- Recipients = (followers ∪ artist-coin holders) - { artist }. +-- Per-recipient row is required: each row has a single user_id in the +-- group_id (matches apps' "fan_club_text_post::user:" +-- group_id with specifier=recipient_id, so the unique constraint +-- (group_id, specifier) dedupes correctly across recipients). +-- +-- Why DEFERRABLE INITIALLY DEFERRED: "top-level" = no comment_threads +-- row, which is inserted later in the same indexer transaction. Same +-- pattern as handle_comment_remix_contest_update.sql. +create or replace function handle_fan_club_text_post() returns trigger as $$ +declare + artist_user_id int; + recipient_id int; + group_id_str text; + data_jsonb jsonb; + is_reply boolean; +begin + if new.entity_type <> 'FanClub' or new.is_delete or not new.is_visible then + return null; + end if; + + -- Artist = new.entity_id (the fan club's owner). Post author must be + -- the artist; fan comments don't fan out. + artist_user_id := new.entity_id; + if new.user_id <> artist_user_id then + return null; + end if; + + -- Skip replies — only root-level posts fan out. + select exists ( + select 1 from comment_threads where comment_id = new.comment_id + ) into is_reply; + if is_reply then + return null; + end if; + + group_id_str := 'fan_club_text_post:' || new.comment_id + || ':user:' || artist_user_id; + data_jsonb := jsonb_build_object( + 'entity_user_id', artist_user_id, + 'comment_id', new.comment_id + ); + + -- Fan out: followers ∪ coin holders, excluding the artist. + for recipient_id in + select u + from ( + select follower_user_id as u + from follows + where followee_user_id = artist_user_id + and is_current = true + and is_delete = false + union + select sub.user_id as u + from sol_user_balances sub + join artist_coins ac on ac.mint = sub.mint + where ac.user_id = artist_user_id + and sub.balance > 0 + ) recipients + where u <> artist_user_id + loop + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + new.blocknumber, + ARRAY[recipient_id], + new.created_at, + 'fan_club_text_post', + recipient_id::text, + group_id_str, + data_jsonb + ) + on conflict do nothing; + end loop; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$ language plpgsql; + + +do $$ begin + create constraint trigger on_fan_club_text_post + after insert on comments + deferrable initially deferred + for each row execute procedure handle_fan_club_text_post(); +exception + when others then null; +end $$; From 17be5c8353e1760f8e70dccb0919f1c8f9f458cf Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 29 May 2026 12:25:41 -0700 Subject: [PATCH 2/3] chore(schema): regenerate dump for comment notification triggers Adds handle_comment_{notification,mention,thread,reaction} and handle_fan_club_text_post functions + their triggers to sql/01_schema.sql so the test-schema template includes them and the comment-notification tests pass. Also picks up the 0204 case-insensitivity view fix that main's dump was missing. Co-Authored-By: Claude Opus 4.7 --- sql/01_schema.sql | 601 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 600 insertions(+), 1 deletion(-) diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 3797b92c..678c1f8c 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -2352,6 +2352,375 @@ end; $$; +-- +-- Name: handle_comment_mention(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.handle_comment_mention() RETURNS trigger + LANGUAGE plpgsql + AS $$ +declare + c_row record; + entity_user_id int; + data_entity_ref int; + is_self_mention boolean; + mention_muted boolean; + owner_mute boolean; + is_owner_mention boolean; +begin + if new.is_delete then + return null; + end if; + + -- Fetch the parent comment for entity context + author. + select user_id, entity_type, entity_id, blocknumber, created_at, is_delete, is_visible + into c_row + from comments + where comment_id = new.comment_id + limit 1; + if not found or c_row.is_delete or not c_row.is_visible then + return null; + end if; + + -- Self-mention is a no-op. + if new.user_id = c_row.user_id then + return null; + end if; + + -- Resolve entity owner — used for the "owner has notifications off" + -- gate when the mention IS the owner. + if c_row.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = c_row.entity_id + and t.is_current = true + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = c_row.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'FanClub' then + entity_user_id := c_row.entity_id; + data_entity_ref := c_row.entity_id; + else + return null; + end if; + + is_owner_mention := (entity_user_id is not null and new.user_id = entity_user_id); + + -- Mentioned user has muted the commenter — skip. + select exists ( + select 1 from muted_users mu + where mu.user_id = new.user_id + and mu.muted_user_id = c_row.user_id + and mu.is_delete = false + ) into mention_muted; + if mention_muted then + return null; + end if; + + -- If the mention is the entity owner AND the owner muted notifications + -- on this entity, skip — matches apps' track_owner_mention_mute logic. + if is_owner_mention then + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = entity_user_id + and cns.entity_type = c_row.entity_type + and cns.entity_id = data_entity_ref + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = entity_user_id + and mu.muted_user_id = c_row.user_id + and mu.is_delete = false + ) into owner_mute; + if owner_mute then + return null; + end if; + end if; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + c_row.blocknumber, + ARRAY[new.user_id], + c_row.created_at, + 'comment_mention', + new.user_id::text, + 'comment_mention:' || new.comment_id, + jsonb_build_object( + 'type', c_row.entity_type, + 'entity_id', data_entity_ref, + 'entity_user_id', entity_user_id, + 'comment_user_id', c_row.user_id, + 'comment_id', new.comment_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$; + + +-- +-- Name: handle_comment_notification(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.handle_comment_notification() RETURNS trigger + LANGUAGE plpgsql + AS $$ +declare + entity_user_id int; + data_entity_ref int; + group_id_str text; + is_reply boolean; + owner_mentioned boolean; + owner_mute boolean; +begin + if new.is_delete or not new.is_visible then + return null; + end if; + + -- Resolve recipient (entity_user_id) + data.entity_id by entity_type. + if new.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = new.entity_id + and t.is_current = true + limit 1; + data_entity_ref := new.entity_id; + elsif new.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = new.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := new.entity_id; + elsif new.entity_type = 'FanClub' then + -- For FanClub, entity_id IS the artist's user_id. + entity_user_id := new.entity_id; + data_entity_ref := new.entity_id; + else + return null; + end if; + + if entity_user_id is null then + return null; + end if; + + -- Skip self-comment. + if new.user_id = entity_user_id then + return null; + end if; + + -- Skip replies (they emit comment_thread instead, to the parent + -- comment author). Deferred so comment_threads is visible. + select exists ( + select 1 from comment_threads where comment_id = new.comment_id + ) into is_reply; + if is_reply then + return null; + end if; + + -- Skip if owner is mentioned in this comment (they get comment_mention + -- instead, also more specific). Deferred so comment_mentions is visible. + select exists ( + select 1 from comment_mentions + where comment_id = new.comment_id + and user_id = entity_user_id + and is_delete = false + ) into owner_mentioned; + if owner_mentioned then + return null; + end if; + + -- Skip if owner muted notifications on this entity (CommentNotificationSetting + -- with is_muted=true) OR muted this commenter (MutedUser). + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = entity_user_id + and cns.entity_type = new.entity_type + and cns.entity_id = data_entity_ref + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = entity_user_id + and mu.muted_user_id = new.user_id + and mu.is_delete = false + ) into owner_mute; + if owner_mute then + return null; + end if; + + group_id_str := 'comment:' || data_entity_ref || ':type:' || new.entity_type; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + new.blocknumber, + ARRAY[entity_user_id], + new.created_at, + 'comment', + new.comment_id::text, + group_id_str, + jsonb_build_object( + 'type', new.entity_type, + 'entity_id', data_entity_ref, + 'comment_user_id', new.user_id, + 'comment_id', new.comment_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$; + + +-- +-- Name: handle_comment_reaction(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.handle_comment_reaction() RETURNS trigger + LANGUAGE plpgsql + AS $$ +declare + c_row record; + entity_user_id int; + data_entity_ref int; + comment_owner_mute boolean; + owner_mute_extra boolean; +begin + if new.is_delete then + return null; + end if; + + -- The comment being reacted to. Use the stored entity_type from the + -- comments row (apps notes clients have sometimes shipped wrong values + -- in the reaction's metadata). + select user_id, entity_type, entity_id, is_delete, is_visible + into c_row + from comments + where comment_id = new.comment_id + limit 1; + if not found or c_row.is_delete or not c_row.is_visible then + return null; + end if; + + -- Self-react is a no-op. + if new.user_id = c_row.user_id then + return null; + end if; + + -- Resolve entity context for the notification payload. + if c_row.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = c_row.entity_id + and t.is_current = true + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = c_row.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := c_row.entity_id; + elsif c_row.entity_type = 'FanClub' then + entity_user_id := c_row.entity_id; + data_entity_ref := c_row.entity_id; + else + entity_user_id := null; + data_entity_ref := c_row.entity_id; + end if; + + -- Comment author muted notifications on this comment OR this reacter. + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = c_row.user_id + and cns.entity_type = 'Comment' + and cns.entity_id = new.comment_id + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = c_row.user_id + and mu.muted_user_id = new.user_id + and mu.is_delete = false + ) into comment_owner_mute; + if comment_owner_mute then + return null; + end if; + + -- Apps' track_owner_mention_mute: if commenter is the entity owner + -- AND owner has notifications off on the entity, drop the reaction + -- notification too (their muted state shouldn't be circumvented by + -- a reaction notification). + if entity_user_id is not null and c_row.user_id = entity_user_id then + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = entity_user_id + and cns.entity_type = c_row.entity_type + and cns.entity_id = data_entity_ref + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = entity_user_id + and mu.muted_user_id = new.user_id + and mu.is_delete = false + ) into owner_mute_extra; + if owner_mute_extra then + return null; + end if; + end if; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + new.blocknumber, + ARRAY[c_row.user_id], + new.created_at, + 'comment_reaction', + new.user_id::text, + 'comment_reaction:' || new.comment_id, + jsonb_build_object( + 'type', c_row.entity_type, + 'entity_id', data_entity_ref, + 'entity_user_id', entity_user_id, + 'comment_id', new.comment_id, + 'reacter_user_id', new.user_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$; + + -- -- Name: handle_comment_remix_contest_update(); Type: FUNCTION; Schema: public; Owner: - -- @@ -2438,6 +2807,118 @@ end; $$; +-- +-- Name: handle_comment_thread(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.handle_comment_thread() RETURNS trigger + LANGUAGE plpgsql + AS $$ +declare + reply_row record; + parent_row record; + entity_user_id int; + data_entity_ref int; + parent_mute boolean; +begin + -- The reply. + select user_id, blocknumber, created_at, is_delete, is_visible + into reply_row + from comments + where comment_id = new.comment_id + limit 1; + if not found or reply_row.is_delete or not reply_row.is_visible then + return null; + end if; + + -- The parent — used for both recipient and the entity context the + -- notification payload includes. + select user_id, entity_type, entity_id + into parent_row + from comments + where comment_id = new.parent_comment_id + limit 1; + if not found then + return null; + end if; + + -- Self-reply is a no-op. + if reply_row.user_id = parent_row.user_id then + return null; + end if; + + -- Resolve the entity owner for the notification payload (matches the + -- entity-type switch in apps' comment.py). + if parent_row.entity_type = 'Track' then + select t.owner_id into entity_user_id + from tracks t + where t.track_id = parent_row.entity_id + and t.is_current = true + limit 1; + data_entity_ref := parent_row.entity_id; + elsif parent_row.entity_type = 'Event' then + select e.user_id into entity_user_id + from events e + where e.event_id = parent_row.entity_id + and e.is_deleted = false + limit 1; + data_entity_ref := parent_row.entity_id; + elsif parent_row.entity_type = 'FanClub' then + entity_user_id := parent_row.entity_id; + data_entity_ref := parent_row.entity_id; + else + -- Unknown entity_type — emit without owner context rather than skip. + entity_user_id := null; + data_entity_ref := parent_row.entity_id; + end if; + + -- Parent author muted this thread or this user. + select exists ( + select 1 from comment_notification_settings cns + where cns.user_id = parent_row.user_id + and cns.entity_type = 'Comment' + and cns.entity_id = new.parent_comment_id + and cns.is_muted = true + ) or exists ( + select 1 from muted_users mu + where mu.user_id = parent_row.user_id + and mu.muted_user_id = reply_row.user_id + and mu.is_delete = false + ) into parent_mute; + if parent_mute then + return null; + end if; + + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + reply_row.blocknumber, + ARRAY[parent_row.user_id], + reply_row.created_at, + 'comment_thread', + new.comment_id::text, + 'comment_thread:' || new.parent_comment_id, + jsonb_build_object( + 'type', parent_row.entity_type, + 'entity_id', data_entity_ref, + 'entity_user_id', entity_user_id, + 'comment_user_id', reply_row.user_id, + 'comment_id', new.comment_id + ) + ) + on conflict do nothing; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$; + + -- -- Name: handle_comms_rpc_log(); Type: FUNCTION; Schema: public; Owner: - -- @@ -2558,6 +3039,89 @@ end; $$; +-- +-- Name: handle_fan_club_text_post(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.handle_fan_club_text_post() RETURNS trigger + LANGUAGE plpgsql + AS $$ +declare + artist_user_id int; + recipient_id int; + group_id_str text; + data_jsonb jsonb; + is_reply boolean; +begin + if new.entity_type <> 'FanClub' or new.is_delete or not new.is_visible then + return null; + end if; + + -- Artist = new.entity_id (the fan club's owner). Post author must be + -- the artist; fan comments don't fan out. + artist_user_id := new.entity_id; + if new.user_id <> artist_user_id then + return null; + end if; + + -- Skip replies — only root-level posts fan out. + select exists ( + select 1 from comment_threads where comment_id = new.comment_id + ) into is_reply; + if is_reply then + return null; + end if; + + group_id_str := 'fan_club_text_post:' || new.comment_id + || ':user:' || artist_user_id; + data_jsonb := jsonb_build_object( + 'entity_user_id', artist_user_id, + 'comment_id', new.comment_id + ); + + -- Fan out: followers ∪ coin holders, excluding the artist. + for recipient_id in + select u + from ( + select follower_user_id as u + from follows + where followee_user_id = artist_user_id + and is_current = true + and is_delete = false + union + select sub.user_id as u + from sol_user_balances sub + join artist_coins ac on ac.mint = sub.mint + where ac.user_id = artist_user_id + and sub.balance > 0 + ) recipients + where u <> artist_user_id + loop + insert into notification + (blocknumber, user_ids, timestamp, type, specifier, group_id, data) + values + ( + new.blocknumber, + ARRAY[recipient_id], + new.created_at, + 'fan_club_text_post', + recipient_id::text, + group_id_str, + data_jsonb + ) + on conflict do nothing; + end loop; + + return null; + +exception + when others then + raise warning 'An error occurred in %: %', tg_name, sqlerrm; + return null; +end; +$$; + + -- -- Name: handle_follow(); Type: FUNCTION; Schema: public; Owner: - -- @@ -9855,7 +10419,7 @@ CREATE VIEW public.v_challenge_disbursements AS rd.created_at, users.user_id FROM (public.sol_reward_disbursements rd - JOIN public.users ON ((((users.wallet)::text = rd.recipient_eth_address) AND (users.is_current = true)))); + JOIN public.users ON (((lower((users.wallet)::text) = rd.recipient_eth_address) AND (users.is_current = true)))); -- @@ -13171,6 +13735,27 @@ CREATE TRIGGER on_chat_message_reaction_changed AFTER INSERT OR DELETE OR UPDATE CREATE TRIGGER on_comment AFTER INSERT ON public.comments FOR EACH ROW EXECUTE FUNCTION public.handle_comment(); +-- +-- Name: comment_mentions on_comment_mention; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER on_comment_mention AFTER INSERT ON public.comment_mentions FOR EACH ROW EXECUTE FUNCTION public.handle_comment_mention(); + + +-- +-- Name: comments on_comment_notification; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE CONSTRAINT TRIGGER on_comment_notification AFTER INSERT ON public.comments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.handle_comment_notification(); + + +-- +-- Name: comment_reactions on_comment_reaction; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER on_comment_reaction AFTER INSERT ON public.comment_reactions FOR EACH ROW EXECUTE FUNCTION public.handle_comment_reaction(); + + -- -- Name: comments on_comment_remix_contest_update; Type: TRIGGER; Schema: public; Owner: - -- @@ -13178,6 +13763,13 @@ CREATE TRIGGER on_comment AFTER INSERT ON public.comments FOR EACH ROW EXECUTE F CREATE CONSTRAINT TRIGGER on_comment_remix_contest_update AFTER INSERT ON public.comments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.handle_comment_remix_contest_update(); +-- +-- Name: comment_threads on_comment_thread; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER on_comment_thread AFTER INSERT ON public.comment_threads FOR EACH ROW EXECUTE FUNCTION public.handle_comment_thread(); + + -- -- Name: sol_meteora_dbc_pools on_dbc_pool_change; Type: TRIGGER; Schema: public; Owner: - -- @@ -13199,6 +13791,13 @@ COMMENT ON TRIGGER on_dbc_pool_change ON public.sol_meteora_dbc_pools IS 'Notifi CREATE TRIGGER on_event AFTER INSERT ON public.events FOR EACH ROW EXECUTE FUNCTION public.handle_event(); +-- +-- Name: comments on_fan_club_text_post; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE CONSTRAINT TRIGGER on_fan_club_text_post AFTER INSERT ON public.comments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.handle_fan_club_text_post(); + + -- -- Name: follows on_follow; Type: TRIGGER; Schema: public; Owner: - -- From 9751cb7677db5efa5b1ca46b424f72802e92b569 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 29 May 2026 13:54:33 -0700 Subject: [PATCH 3/3] fix(test): connect to maintenance DB, not template, in CreateTestDatabase CREATE DATABASE ... TEMPLATE fails with SQLSTATE 55006 when any session is connected to the source template. go test runs each package as its own process, so the package-level testMutex can't prevent one process holding a template connection open while another clones it. Connect to the postgres maintenance DB instead so no test process ever holds a template connection. Co-Authored-By: Claude Opus 4.7 --- database/test_database.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/database/test_database.go b/database/test_database.go index 94613875..d6684a13 100644 --- a/database/test_database.go +++ b/database/test_database.go @@ -30,7 +30,12 @@ func CreateTestDatabase(t *testing.T, template string) *pgxpool.Pool { testMutex.Lock() defer testMutex.Unlock() - conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/"+template) + // Connect to the maintenance DB, not the template itself: CREATE DATABASE + // ... TEMPLATE fails with SQLSTATE 55006 if any session is connected to the + // source. go test runs each package as its own process, so a Go-level mutex + // can't prevent one process holding a template connection open while another + // clones it. + conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/postgres") if err != nil { panic(fmt.Errorf("failed to connect to database: %w", err)) } @@ -55,7 +60,7 @@ func CreateTestDatabase(t *testing.T, template string) *pgxpool.Pool { testMutex.Lock() defer testMutex.Unlock() - conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/"+template) + conn, err := pgx.Connect(ctx, "postgres://postgres:example@localhost:21300/postgres") require.NoError(t, err) defer conn.Close(ctx)