From 81b358567c1d8217a9a1c12cff5fbba0d2c63bb6 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 22 May 2026 14:29:16 -0700 Subject: [PATCH] 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 $$;