Skip to content

Commit 081664b

Browse files
feat(notifications): handle_comment_remix_contest_update trigger (#848)
## Summary Adds the missing fourth contest notification — `remix_contest_update`. Sibling of the three already in this repo: | Notification | File | |---|---| | `fan_remix_contest_started` | `handle_event.sql` | | `artist_remix_contest_submissions` | `handle_track.sql` | | `fan_remix_contest_submission` | `handle_track.sql` | | **`remix_contest_update`** | **`handle_comment_remix_contest_update.sql`** (this PR) | Triggers when the contest host posts a TOP-LEVEL comment on their own `remix_contest` event. Fans out one notification per active event subscriber (excluding the host). Mirrors AudiusProject/apps#14159's python `create_comment` block. ## Recipient set + data shape Matches the python indexer 1:1 so existing frontend renderers (the web + mobile components shipped in apps#14159) consume it without changes. - Recipient set: `subscriptions WHERE entity_type='Event' AND user_id=event_id AND is_current AND NOT is_delete`, excluding the host. - `group_id`: `remix_contest_update:{comment_id}:event:{event_id}` - `specifier`: `{recipient_id}` (per-recipient row; `(group_id, specifier)` is the unique constraint) - `data`: `{event_id, entity_id (= contest's parent track), entity_user_id (= host), comment_id}` ## Why `DEFERRABLE INITIALLY DEFERRED` The "is_reply" check looks at `comment_threads`. The indexer inserts the `comment_threads` row AFTER the `comments` row in the same transaction. A plain `AFTER INSERT` trigger on `comments` fires before `comment_threads` is visible and would misclassify every reply as top-level. Using a `CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED` fires the trigger at commit time, by which point both rows are visible. The trigger function checks `EXISTS(SELECT 1 FROM comment_threads WHERE comment_id = NEW.comment_id)` to detect replies. Standard `exception when others` wrapper so a bug in the notification path never aborts the user's comment insert. ## Tests Three tests in `api/v1_event_comments_notification_test.go`, all exercising the trigger via `app.writePool`: - **`TestRemixContestUpdate_NotifiesEventSubscribers`** — host top-level comment on own event: one notification per non-host subscriber, with the expected `group_id`/`specifier`/`data`/`user_ids` shape. Host's own (self-)subscription is excluded. - **`TestRemixContestUpdate_SkipsReplies`** — host posts a reply (both `comments` and `comment_threads` INSERTs in the same transaction): zero notifications. Exercises the deferred-trigger contract. - **`TestRemixContestUpdate_SkipsNonHostComments`** — random non-host commenter on the event: zero notifications. ## Companion change OpenAudio/go-openaudio#311 ports the `auto_subscribe_to_contest_on_submission` half of apps#14159 to the Go ETL — without those `subscriptions` rows landing, the recipient set this trigger fans out to (and the two in `handle_track.sql`) would be empty. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1cab659 commit 081664b

3 files changed

Lines changed: 622 additions & 12 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"api.audius.co/database"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestRemixContestUpdate_NotifiesEventSubscribers exercises the
14+
// handle_comment_remix_contest_update trigger. When the contest host posts
15+
// a top-level (non-reply) comment on their own remix-contest event, every
16+
// active event subscriber (excluding the host) should receive one
17+
// `remix_contest_update` notification row.
18+
func TestRemixContestUpdate_NotifiesEventSubscribers(t *testing.T) {
19+
app := emptyTestApp(t)
20+
ctx := context.Background()
21+
require.NotNil(t, app.writePool, "test requires write pool")
22+
23+
hostId := 7101
24+
subA := 7102
25+
subB := 7103
26+
parentTrackId := 7201
27+
eventId := 7301
28+
commentId := 7401
29+
30+
now := time.Now().UTC()
31+
fixtures := database.FixtureMap{
32+
// comments has an FK on blocknumber → blocks.number; the comment we
33+
// insert later uses blocknumber=100, so seed it.
34+
"blocks": []map[string]any{
35+
{"blockhash": "rcu-blk-100", "parenthash": nil, "number": 100},
36+
},
37+
"users": []map[string]any{
38+
{"user_id": hostId, "handle": "rc_host"},
39+
{"user_id": subA, "handle": "rc_subA"},
40+
{"user_id": subB, "handle": "rc_subB"},
41+
},
42+
"tracks": []map[string]any{
43+
{
44+
"track_id": parentTrackId,
45+
"owner_id": hostId,
46+
"title": "Parent Track",
47+
"created_at": now,
48+
"updated_at": now,
49+
},
50+
},
51+
"events": []map[string]any{
52+
{
53+
"event_id": eventId,
54+
"event_type": "remix_contest",
55+
"entity_id": parentTrackId,
56+
"user_id": hostId,
57+
"created_at": now,
58+
"end_date": now.Add(7 * 24 * time.Hour),
59+
},
60+
},
61+
"subscriptions": []map[string]any{
62+
// Both subA and subB subscribe to the event. Host also "subscribes"
63+
// to their own event (which should still be excluded by the trigger).
64+
{
65+
"subscriber_id": subA,
66+
"user_id": eventId,
67+
"entity_type": "Event",
68+
"entity_id": eventId,
69+
"is_current": true,
70+
"is_delete": false,
71+
"created_at": now,
72+
"txhash": "seed-subA",
73+
},
74+
{
75+
"subscriber_id": subB,
76+
"user_id": eventId,
77+
"entity_type": "Event",
78+
"entity_id": eventId,
79+
"is_current": true,
80+
"is_delete": false,
81+
"created_at": now,
82+
"txhash": "seed-subB",
83+
},
84+
{
85+
"subscriber_id": hostId,
86+
"user_id": eventId,
87+
"entity_type": "Event",
88+
"entity_id": eventId,
89+
"is_current": true,
90+
"is_delete": false,
91+
"created_at": now,
92+
"txhash": "seed-subhost",
93+
},
94+
},
95+
}
96+
database.Seed(app.pool.Replicas[0], fixtures)
97+
98+
// Host posts a TOP-LEVEL comment on their own event. Single auto-commit
99+
// statement → deferred trigger fires at the commit → comment_threads is
100+
// empty for this comment_id → treated as top-level → fans out.
101+
_, err := app.writePool.Exec(ctx, `
102+
INSERT INTO comments (
103+
comment_id, user_id, entity_id, entity_type,
104+
text, is_delete, is_visible, is_edited,
105+
created_at, updated_at,
106+
txhash, blockhash, blocknumber
107+
) VALUES (
108+
$1, $2, $3, 'Event',
109+
'Round 1 is live!', false, true, false,
110+
$4, $4,
111+
'tx-rcu-1', 'blk-rcu-1', 100
112+
)
113+
`, commentId, hostId, eventId, now)
114+
require.NoError(t, err)
115+
116+
// Each non-host subscriber should now have exactly one
117+
// remix_contest_update notification.
118+
type notifRow struct {
119+
Specifier string
120+
GroupId string
121+
UserIds []int32
122+
EventId int
123+
EntityId int
124+
EntityUserId int
125+
CommentId int
126+
}
127+
rows, err := app.writePool.Query(ctx, `
128+
SELECT specifier, group_id, user_ids,
129+
(data->>'event_id')::int,
130+
(data->>'entity_id')::int,
131+
(data->>'entity_user_id')::int,
132+
(data->>'comment_id')::int
133+
FROM notification
134+
WHERE type = 'remix_contest_update'
135+
AND group_id = $1
136+
ORDER BY specifier ASC
137+
`, "remix_contest_update:7401:event:7301")
138+
require.NoError(t, err)
139+
defer rows.Close()
140+
141+
var got []notifRow
142+
for rows.Next() {
143+
var r notifRow
144+
require.NoError(t, rows.Scan(&r.Specifier, &r.GroupId, &r.UserIds,
145+
&r.EventId, &r.EntityId, &r.EntityUserId, &r.CommentId))
146+
got = append(got, r)
147+
}
148+
require.NoError(t, rows.Err())
149+
150+
require.Len(t, got, 2, "expected exactly one notification per non-host subscriber")
151+
152+
recipients := map[int32]bool{}
153+
for _, r := range got {
154+
assert.Equal(t, eventId, r.EventId)
155+
assert.Equal(t, parentTrackId, r.EntityId, "entity_id = the contest's parent track")
156+
assert.Equal(t, hostId, r.EntityUserId, "entity_user_id = the event host")
157+
assert.Equal(t, commentId, r.CommentId)
158+
require.Len(t, r.UserIds, 1)
159+
recipients[r.UserIds[0]] = true
160+
}
161+
assert.True(t, recipients[int32(subA)], "subA must receive the notification")
162+
assert.True(t, recipients[int32(subB)], "subB must receive the notification")
163+
assert.False(t, recipients[int32(hostId)], "host must NOT receive a notification for their own post")
164+
}
165+
166+
// TestRemixContestUpdate_SkipsReplies verifies that a HOST reply (a
167+
// comment with a comment_threads row inserted in the same transaction)
168+
// does NOT trigger remix_contest_update. Only top-level posts do.
169+
func TestRemixContestUpdate_SkipsReplies(t *testing.T) {
170+
app := emptyTestApp(t)
171+
ctx := context.Background()
172+
require.NotNil(t, app.writePool, "test requires write pool")
173+
174+
hostId := 7501
175+
subId := 7502
176+
parentTrackId := 7601
177+
eventId := 7701
178+
parentCommentId := 7801
179+
replyCommentId := 7802
180+
181+
now := time.Now().UTC()
182+
fixtures := database.FixtureMap{
183+
// comments has an FK on blocknumber → blocks.number; both the seeded
184+
// parent comment (blocknumber=99) and the reply we insert later
185+
// (blocknumber=100) need their blocks rows.
186+
"blocks": []map[string]any{
187+
{"blockhash": "rcu-blk-99", "parenthash": nil, "number": 99},
188+
{"blockhash": "rcu-blk-100", "parenthash": "rcu-blk-99", "number": 100},
189+
},
190+
"users": []map[string]any{
191+
{"user_id": hostId, "handle": "rcr_host"},
192+
{"user_id": subId, "handle": "rcr_sub"},
193+
},
194+
"tracks": []map[string]any{
195+
{"track_id": parentTrackId, "owner_id": hostId, "title": "Parent",
196+
"created_at": now, "updated_at": now},
197+
},
198+
"events": []map[string]any{
199+
{"event_id": eventId, "event_type": "remix_contest",
200+
"entity_id": parentTrackId, "user_id": hostId,
201+
"created_at": now, "end_date": now.Add(7 * 24 * time.Hour)},
202+
},
203+
"subscriptions": []map[string]any{
204+
{"subscriber_id": subId, "user_id": eventId, "entity_type": "Event",
205+
"entity_id": eventId, "is_current": true, "is_delete": false,
206+
"created_at": now, "txhash": "seed-sub-r"},
207+
},
208+
// Seed an existing top-level host comment to be the reply's parent.
209+
"comments": []map[string]any{
210+
{"comment_id": parentCommentId, "user_id": hostId,
211+
"entity_id": eventId, "entity_type": "Event",
212+
"text": "opener", "is_delete": false, "is_visible": true,
213+
"created_at": now, "updated_at": now,
214+
"txhash": "tx-parent", "blockhash": "rcu-blk-99", "blocknumber": 99},
215+
},
216+
}
217+
database.Seed(app.pool.Replicas[0], fixtures)
218+
219+
// We need the comment INSERT + comment_threads INSERT in the same tx so
220+
// the deferred trigger sees the thread row at commit time.
221+
tx, err := app.writePool.Begin(ctx)
222+
require.NoError(t, err)
223+
_, err = tx.Exec(ctx, `
224+
INSERT INTO comments (
225+
comment_id, user_id, entity_id, entity_type,
226+
text, is_delete, is_visible, is_edited,
227+
created_at, updated_at,
228+
txhash, blockhash, blocknumber
229+
) VALUES (
230+
$1, $2, $3, 'Event',
231+
'my reply', false, true, false,
232+
$4, $4,
233+
'tx-reply', 'blk-reply', 100
234+
)
235+
`, replyCommentId, hostId, eventId, now)
236+
require.NoError(t, err)
237+
_, err = tx.Exec(ctx, `
238+
INSERT INTO comment_threads (parent_comment_id, comment_id)
239+
VALUES ($1, $2)
240+
`, parentCommentId, replyCommentId)
241+
require.NoError(t, err)
242+
require.NoError(t, tx.Commit(ctx))
243+
244+
// No remix_contest_update notification should exist for the reply.
245+
var n int
246+
err = app.writePool.QueryRow(ctx, `
247+
SELECT count(*) FROM notification
248+
WHERE type = 'remix_contest_update'
249+
AND group_id = $1
250+
`, "remix_contest_update:7802:event:7701").Scan(&n)
251+
require.NoError(t, err)
252+
assert.Equal(t, 0, n, "host replies must NOT trigger remix_contest_update")
253+
}
254+
255+
// TestRemixContestUpdate_SkipsNonHostComments verifies that a NON-host
256+
// commenter on the event does not trigger the notification, even if
257+
// they're commenting top-level.
258+
func TestRemixContestUpdate_SkipsNonHostComments(t *testing.T) {
259+
app := emptyTestApp(t)
260+
ctx := context.Background()
261+
require.NotNil(t, app.writePool, "test requires write pool")
262+
263+
hostId := 7901
264+
commenterId := 7902
265+
subId := 7903
266+
parentTrackId := 7801
267+
eventId := 7811
268+
commentId := 7821
269+
270+
now := time.Now().UTC()
271+
fixtures := database.FixtureMap{
272+
// comments has an FK on blocknumber → blocks.number.
273+
"blocks": []map[string]any{
274+
{"blockhash": "rcu-blk-100", "parenthash": nil, "number": 100},
275+
},
276+
"users": []map[string]any{
277+
{"user_id": hostId, "handle": "rcn_host"},
278+
{"user_id": commenterId, "handle": "rcn_commenter"},
279+
{"user_id": subId, "handle": "rcn_sub"},
280+
},
281+
"tracks": []map[string]any{
282+
{"track_id": parentTrackId, "owner_id": hostId, "title": "Parent",
283+
"created_at": now, "updated_at": now},
284+
},
285+
"events": []map[string]any{
286+
{"event_id": eventId, "event_type": "remix_contest",
287+
"entity_id": parentTrackId, "user_id": hostId,
288+
"created_at": now, "end_date": now.Add(7 * 24 * time.Hour)},
289+
},
290+
"subscriptions": []map[string]any{
291+
{"subscriber_id": subId, "user_id": eventId, "entity_type": "Event",
292+
"entity_id": eventId, "is_current": true, "is_delete": false,
293+
"created_at": now, "txhash": "seed-sub-nh"},
294+
},
295+
}
296+
database.Seed(app.pool.Replicas[0], fixtures)
297+
298+
// Non-host posts a top-level comment on the event.
299+
_, err := app.writePool.Exec(ctx, `
300+
INSERT INTO comments (
301+
comment_id, user_id, entity_id, entity_type,
302+
text, is_delete, is_visible, is_edited,
303+
created_at, updated_at,
304+
txhash, blockhash, blocknumber
305+
) VALUES (
306+
$1, $2, $3, 'Event',
307+
'I hope I win!', false, true, false,
308+
$4, $4,
309+
'tx-nh', 'blk-nh', 100
310+
)
311+
`, commentId, commenterId, eventId, now)
312+
require.NoError(t, err)
313+
314+
var n int
315+
err = app.writePool.QueryRow(ctx, `
316+
SELECT count(*) FROM notification WHERE type = 'remix_contest_update'
317+
`).Scan(&n)
318+
require.NoError(t, err)
319+
assert.Equal(t, 0, n, "non-host comments must NOT trigger remix_contest_update")
320+
}

0 commit comments

Comments
 (0)