Skip to content

Commit d1136bd

Browse files
authored
feat(events): sort remix-contests by open/ended × audius-followed, entries desc (#845)
1 parent de53f22 commit d1136bd

3 files changed

Lines changed: 226 additions & 74 deletions

File tree

api/v1_events_remix_contests.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ type GetRemixContestsParams struct {
1818

1919
// v1EventsRemixContests returns remix-contest events from the events table.
2020
// Sort priority:
21-
// 1. Featured-audience-account contests (config.Cfg.FeaturedAudienceUserID; 0 disables).
22-
// 2. Contests that have at least one entry.
23-
// 3. Ended contests with zero entries land last.
21+
// 1. Open contests whose host is followed by the Audius account.
22+
// 2. Open contests whose host is not followed by the Audius account.
23+
// 3. Ended contests whose host is followed by the Audius account.
24+
// 4. Ended contests whose host is not followed by the Audius account.
2425
//
25-
// Within each group we keep the existing active-first / soonest-ending sort.
26+
// The Audius account is config.Cfg.FeaturedAudienceUserID; 0 disables the
27+
// follow-based tiebreak (groups collapse to open-vs-ended only).
28+
// Within each group, results are sorted by entry count descending, then by
29+
// the active-first / soonest-ending sort, then event_id ASC as a stable tiebreak.
2630
// Supports pagination and an optional `status` filter (active | ended | all).
2731
func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
2832
params := GetRemixContestsParams{}
@@ -120,10 +124,23 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
120124
) ec ON true
121125
WHERE ` + strings.Join(filters, " AND ") + `
122126
ORDER BY
123-
CASE WHEN @featured_user_id::int4 != 0 AND e.user_id = @featured_user_id::int4 THEN 0 ELSE 1 END ASC,
124-
CASE WHEN COALESCE(ec.entry_count, 0) > 0 THEN 0 ELSE 1 END ASC,
125-
CASE WHEN e.end_date IS NOT NULL AND e.end_date <= NOW() AND COALESCE(ec.entry_count, 0) = 0 THEN 1 ELSE 0 END ASC,
127+
-- Primary: open (0) before ended (1).
126128
CASE WHEN e.end_date IS NULL OR e.end_date > NOW() THEN 0 ELSE 1 END ASC,
129+
-- Secondary: Audius-followed hosts (0) before unfollowed (1).
130+
-- When @audius_user_id is 0 the tiebreak collapses (all rows score 1).
131+
CASE
132+
WHEN @audius_user_id::int4 != 0
133+
AND EXISTS (
134+
SELECT 1 FROM follows f
135+
WHERE f.follower_user_id = @audius_user_id::int4
136+
AND f.followee_user_id = e.user_id
137+
AND f.is_current = true
138+
AND f.is_delete = false
139+
) THEN 0
140+
ELSE 1
141+
END ASC,
142+
-- Within each group, more entries first.
143+
COALESCE(ec.entry_count, 0) DESC,
127144
CASE WHEN e.end_date IS NULL OR e.end_date > NOW() THEN e.end_date END ASC NULLS LAST,
128145
CASE WHEN e.end_date IS NOT NULL AND e.end_date <= NOW() THEN e.end_date END DESC,
129146
e.event_id ASC
@@ -133,7 +150,7 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
133150
rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{
134151
"limit": params.Limit,
135152
"offset": params.Offset,
136-
"featured_user_id": config.Cfg.FeaturedAudienceUserID,
153+
"audius_user_id": config.Cfg.FeaturedAudienceUserID,
137154
"karmaCommentCountThreshold": karmaCommentCountThreshold,
138155
})
139156
if err != nil {

api/v1_events_remix_contests_test.go

Lines changed: 197 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -227,28 +227,31 @@ func TestRemixContestsDiscoveryPage(t *testing.T) {
227227
})
228228
}
229229

230-
// TestRemixContestsSortPriority covers the multi-tier sort:
231-
// 1. Featured-audience-account contests come first.
232-
// 2. Then contests with at least one entry.
233-
// 3. Ended contests with zero entries land at the bottom.
230+
// TestRemixContestsSortPriority covers the four-group sort:
231+
// 1. Open + Audius-followed host.
232+
// 2. Open + unfollowed host.
233+
// 3. Ended + Audius-followed host.
234+
// 4. Ended + unfollowed host.
234235
//
235-
// Within each group the existing active-first / soonest-ending sort still
236-
// applies — we don't reassert that here because TestRemixContestsDiscoveryPage
237-
// already covers it.
236+
// Within each group, contests sort by entry_count DESC.
238237
func TestRemixContestsSortPriority(t *testing.T) {
239238
app := emptyTestApp(t)
240239

241-
featuredHostID := 9101 // contests by this user must sort first
242-
regularHostID := 9102
243-
ownerID := 9103
240+
audiusUserID := 9100 // the "Audius" account whose follows reorder each open/ended group
241+
followedHostID := 9101
242+
unfollowedHostID := 9102
244243
remixerID := 9104
245244

246-
// Track ids for each contest's parent track.
247-
featuredEndedZeroTrackID := 8201 // featured + ended + zero entries → still group 1
248-
hasEntriesActiveTrackID := 8202 // group 2 (has entries)
249-
hasEntriesEndedTrackID := 8203 // group 2 (has entries, ended)
250-
activeZeroTrackID := 8204 // group 3 (active, no entries — neither featured nor has-entries nor ended-empty)
251-
endedZeroTrackID := 8205 // group 4 (ended + zero entries) — must be LAST
245+
// Two contests per group so we can also exercise the entry_count DESC
246+
// tiebreak inside each group.
247+
openFollowedHighEntriesTrackID := 8201 // group 1, 2 entries
248+
openFollowedLowEntriesTrackID := 8202 // group 1, 0 entries
249+
openUnfollowedHighTrackID := 8203 // group 2, 2 entries
250+
openUnfollowedLowTrackID := 8204 // group 2, 0 entries
251+
endedFollowedHighTrackID := 8205 // group 3, 2 entries
252+
endedFollowedLowTrackID := 8206 // group 3, 0 entries
253+
endedUnfollowedHighTrackID := 8207 // group 4, 2 entries
254+
endedUnfollowedLowTrackID := 8208 // group 4, 0 entries
252255

253256
farFuture := parseTime(t, "2099-01-01")
254257
farPast := parseTime(t, "2024-02-10")
@@ -259,104 +262,234 @@ func TestRemixContestsSortPriority(t *testing.T) {
259262
"events": []map[string]any{
260263
{
261264
"event_id": 601, "event_type": "remix_contest", "entity_type": "track",
262-
"entity_id": featuredEndedZeroTrackID, "user_id": featuredHostID,
263-
"created_at": contestStart, "end_date": farPast,
265+
"entity_id": openFollowedHighEntriesTrackID, "user_id": followedHostID,
266+
"created_at": contestStart, "end_date": farFuture,
264267
},
265268
{
266269
"event_id": 602, "event_type": "remix_contest", "entity_type": "track",
267-
"entity_id": hasEntriesActiveTrackID, "user_id": regularHostID,
270+
"entity_id": openFollowedLowEntriesTrackID, "user_id": followedHostID,
268271
"created_at": contestStart, "end_date": farFuture,
269272
},
270273
{
271274
"event_id": 603, "event_type": "remix_contest", "entity_type": "track",
272-
"entity_id": hasEntriesEndedTrackID, "user_id": regularHostID,
273-
"created_at": contestStart, "end_date": farPast,
275+
"entity_id": openUnfollowedHighTrackID, "user_id": unfollowedHostID,
276+
"created_at": contestStart, "end_date": farFuture,
274277
},
275278
{
276279
"event_id": 604, "event_type": "remix_contest", "entity_type": "track",
277-
"entity_id": activeZeroTrackID, "user_id": regularHostID,
280+
"entity_id": openUnfollowedLowTrackID, "user_id": unfollowedHostID,
278281
"created_at": contestStart, "end_date": farFuture,
279282
},
280283
{
281284
"event_id": 605, "event_type": "remix_contest", "entity_type": "track",
282-
"entity_id": endedZeroTrackID, "user_id": regularHostID,
285+
"entity_id": endedFollowedHighTrackID, "user_id": followedHostID,
286+
"created_at": contestStart, "end_date": farPast,
287+
},
288+
{
289+
"event_id": 606, "event_type": "remix_contest", "entity_type": "track",
290+
"entity_id": endedFollowedLowTrackID, "user_id": followedHostID,
291+
"created_at": contestStart, "end_date": farPast,
292+
},
293+
{
294+
"event_id": 607, "event_type": "remix_contest", "entity_type": "track",
295+
"entity_id": endedUnfollowedHighTrackID, "user_id": unfollowedHostID,
296+
"created_at": contestStart, "end_date": farPast,
297+
},
298+
{
299+
"event_id": 608, "event_type": "remix_contest", "entity_type": "track",
300+
"entity_id": endedUnfollowedLowTrackID, "user_id": unfollowedHostID,
283301
"created_at": contestStart, "end_date": farPast,
284302
},
285303
},
286304
"users": []map[string]any{
287-
{"user_id": featuredHostID, "handle": "featured"},
288-
{"user_id": regularHostID, "handle": "regular"},
289-
{"user_id": ownerID, "handle": "owner"},
305+
{"user_id": audiusUserID, "handle": "audius"},
306+
{"user_id": followedHostID, "handle": "followed"},
307+
{"user_id": unfollowedHostID, "handle": "unfollowed"},
290308
{"user_id": remixerID, "handle": "remixer"},
291309
},
310+
"follows": []map[string]any{
311+
{"follower_user_id": audiusUserID, "followee_user_id": followedHostID},
312+
},
292313
"tracks": []map[string]any{
293-
{"track_id": featuredEndedZeroTrackID, "owner_id": featuredHostID, "created_at": contestStart},
294-
{"track_id": hasEntriesActiveTrackID, "owner_id": regularHostID, "created_at": contestStart},
295-
{"track_id": hasEntriesEndedTrackID, "owner_id": regularHostID, "created_at": contestStart},
296-
{"track_id": activeZeroTrackID, "owner_id": regularHostID, "created_at": contestStart},
297-
{"track_id": endedZeroTrackID, "owner_id": regularHostID, "created_at": contestStart},
298-
// Entries — only for the two has-entries contests.
299-
{"track_id": 8302, "owner_id": remixerID, "created_at": inWindow},
300-
{"track_id": 8303, "owner_id": remixerID, "created_at": inWindow},
314+
{"track_id": openFollowedHighEntriesTrackID, "owner_id": followedHostID, "created_at": contestStart},
315+
{"track_id": openFollowedLowEntriesTrackID, "owner_id": followedHostID, "created_at": contestStart},
316+
{"track_id": openUnfollowedHighTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
317+
{"track_id": openUnfollowedLowTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
318+
{"track_id": endedFollowedHighTrackID, "owner_id": followedHostID, "created_at": contestStart},
319+
{"track_id": endedFollowedLowTrackID, "owner_id": followedHostID, "created_at": contestStart},
320+
{"track_id": endedUnfollowedHighTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
321+
{"track_id": endedUnfollowedLowTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
322+
// Two entries each for the "high" contest in every group.
323+
{"track_id": 8311, "owner_id": remixerID, "created_at": inWindow},
324+
{"track_id": 8312, "owner_id": remixerID, "created_at": inWindow},
325+
{"track_id": 8313, "owner_id": remixerID, "created_at": inWindow},
326+
{"track_id": 8314, "owner_id": remixerID, "created_at": inWindow},
327+
{"track_id": 8315, "owner_id": remixerID, "created_at": inWindow},
328+
{"track_id": 8316, "owner_id": remixerID, "created_at": inWindow},
329+
{"track_id": 8317, "owner_id": remixerID, "created_at": inWindow},
330+
{"track_id": 8318, "owner_id": remixerID, "created_at": inWindow},
301331
},
302332
"remixes": []map[string]any{
303-
{"parent_track_id": hasEntriesActiveTrackID, "child_track_id": 8302},
304-
{"parent_track_id": hasEntriesEndedTrackID, "child_track_id": 8303},
333+
{"parent_track_id": openFollowedHighEntriesTrackID, "child_track_id": 8311},
334+
{"parent_track_id": openFollowedHighEntriesTrackID, "child_track_id": 8312},
335+
{"parent_track_id": openUnfollowedHighTrackID, "child_track_id": 8313},
336+
{"parent_track_id": openUnfollowedHighTrackID, "child_track_id": 8314},
337+
{"parent_track_id": endedFollowedHighTrackID, "child_track_id": 8315},
338+
{"parent_track_id": endedFollowedHighTrackID, "child_track_id": 8316},
339+
{"parent_track_id": endedUnfollowedHighTrackID, "child_track_id": 8317},
340+
{"parent_track_id": endedUnfollowedHighTrackID, "child_track_id": 8318},
305341
},
306342
}
307343
database.Seed(app.pool.Replicas[0], fixtures)
308344

309-
featuredEvent := trashid.MustEncodeHashID(601)
310-
hasActiveEvent := trashid.MustEncodeHashID(602)
311-
hasEndedEvent := trashid.MustEncodeHashID(603)
312-
activeZeroEvent := trashid.MustEncodeHashID(604)
313-
endedZeroEvent := trashid.MustEncodeHashID(605)
345+
openFollowedHigh := trashid.MustEncodeHashID(601)
346+
openFollowedLow := trashid.MustEncodeHashID(602)
347+
openUnfollowedHigh := trashid.MustEncodeHashID(603)
348+
openUnfollowedLow := trashid.MustEncodeHashID(604)
349+
endedFollowedHigh := trashid.MustEncodeHashID(605)
350+
endedFollowedLow := trashid.MustEncodeHashID(606)
351+
endedUnfollowedHigh := trashid.MustEncodeHashID(607)
352+
endedUnfollowedLow := trashid.MustEncodeHashID(608)
314353

315-
t.Run("featured account contests sort first, ended-zero-entries last", func(t *testing.T) {
354+
t.Run("open-vs-ended × followed-vs-unfollowed, entries desc within group", func(t *testing.T) {
316355
prev := config.Cfg.FeaturedAudienceUserID
317-
config.Cfg.FeaturedAudienceUserID = int32(featuredHostID)
356+
config.Cfg.FeaturedAudienceUserID = int32(audiusUserID)
318357
t.Cleanup(func() { config.Cfg.FeaturedAudienceUserID = prev })
319358

320359
status, body := testGet(t, app, "/v1/events/remix-contests")
321360
assert.Equal(t, 200, status)
322361

323362
jsonAssert(t, body, map[string]any{
324-
"data.#": 5,
325-
"data.0.event_id": featuredEvent, // featured (group 1)
326-
"data.1.event_id": hasActiveEvent, // has entries, active (group 2)
327-
"data.2.event_id": hasEndedEvent, // has entries, ended (group 2)
328-
"data.3.event_id": activeZeroEvent, // active, zero entries (group 3 — not ended-empty)
329-
"data.4.event_id": endedZeroEvent, // ended + zero entries (group 4, LAST)
363+
"data.#": 8,
364+
"data.0.event_id": openFollowedHigh, // group 1, 2 entries
365+
"data.1.event_id": openFollowedLow, // group 1, 0 entries
366+
"data.2.event_id": openUnfollowedHigh, // group 2, 2 entries
367+
"data.3.event_id": openUnfollowedLow, // group 2, 0 entries
368+
"data.4.event_id": endedFollowedHigh, // group 3, 2 entries
369+
"data.5.event_id": endedFollowedLow, // group 3, 0 entries
370+
"data.6.event_id": endedUnfollowedHigh, // group 4, 2 entries
371+
"data.7.event_id": endedUnfollowedLow, // group 4, 0 entries
330372
})
331373
})
332374

333-
t.Run("with featured user unset, featured contest falls back to entry-based sort", func(t *testing.T) {
375+
t.Run("with audius user unset, only the open-vs-ended split applies", func(t *testing.T) {
334376
prev := config.Cfg.FeaturedAudienceUserID
335377
config.Cfg.FeaturedAudienceUserID = 0
336378
t.Cleanup(func() { config.Cfg.FeaturedAudienceUserID = prev })
337379

338380
status, body := testGet(t, app, "/v1/events/remix-contests")
339381
assert.Equal(t, 200, status)
340382

341-
// featuredEvent is now ended-with-zero-entries, so it should sort
342-
// alongside endedZeroEvent at the bottom (group 4). The two has-entries
343-
// contests are group 2, activeZeroEvent is group 3.
383+
// Without the Audius user, the follow-based tiebreak collapses. The
384+
// remaining order is: open (4 rows) → ended (4 rows), each block sorted
385+
// by entry_count DESC then event_id ASC.
344386
jsonAssert(t, body, map[string]any{
345-
"data.#": 5,
346-
"data.0.event_id": hasActiveEvent,
347-
"data.1.event_id": hasEndedEvent,
348-
"data.2.event_id": activeZeroEvent,
349-
})
350-
// Last two entries are both ended-zero-entries — order within is
351-
// determined by end_date DESC then event_id; both events share end_date
352-
// (farPast), so the smaller event_id (601) comes first.
353-
jsonAssert(t, body, map[string]any{
354-
"data.3.event_id": featuredEvent,
355-
"data.4.event_id": endedZeroEvent,
387+
"data.#": 8,
388+
// Open contests with 2 entries (601, 603), then with 0 (602, 604).
389+
"data.0.event_id": openFollowedHigh, // 601, 2 entries
390+
"data.1.event_id": openUnfollowedHigh, // 603, 2 entries
391+
"data.2.event_id": openFollowedLow, // 602, 0 entries
392+
"data.3.event_id": openUnfollowedLow, // 604, 0 entries
393+
// Ended contests with 2 entries (605, 607), then with 0 (606, 608).
394+
"data.4.event_id": endedFollowedHigh, // 605
395+
"data.5.event_id": endedUnfollowedHigh, // 607
396+
"data.6.event_id": endedFollowedLow, // 606
397+
"data.7.event_id": endedUnfollowedLow, // 608
356398
})
357399
})
358400
}
359401

402+
// TestRemixContestsFollowFilterIgnoresStaleRows verifies that the
403+
// follow-based tiebreak only counts live `follows` rows. A soft-deleted
404+
// (is_delete=true) or non-current (is_current=false) follow must not promote
405+
// a host into the "followed" sub-group.
406+
func TestRemixContestsFollowFilterIgnoresStaleRows(t *testing.T) {
407+
app := emptyTestApp(t)
408+
409+
audiusUserID := 9200
410+
liveFollowHostID := 9201
411+
deletedFollowHostID := 9202
412+
staleFollowHostID := 9203
413+
remixerID := 9210
414+
415+
liveTrackID := 8401
416+
deletedTrackID := 8402
417+
staleTrackID := 8403
418+
419+
farFuture := parseTime(t, "2099-01-01")
420+
contestStart := parseTime(t, "2024-01-02")
421+
inWindow := parseTime(t, "2024-01-03")
422+
423+
fixtures := database.FixtureMap{
424+
"events": []map[string]any{
425+
// All three contests are open with the same entry_count (1), so the
426+
// only differentiator left is the follow tiebreak.
427+
{
428+
"event_id": 701, "event_type": "remix_contest", "entity_type": "track",
429+
"entity_id": liveTrackID, "user_id": liveFollowHostID,
430+
"created_at": contestStart, "end_date": farFuture,
431+
},
432+
{
433+
"event_id": 702, "event_type": "remix_contest", "entity_type": "track",
434+
"entity_id": deletedTrackID, "user_id": deletedFollowHostID,
435+
"created_at": contestStart, "end_date": farFuture,
436+
},
437+
{
438+
"event_id": 703, "event_type": "remix_contest", "entity_type": "track",
439+
"entity_id": staleTrackID, "user_id": staleFollowHostID,
440+
"created_at": contestStart, "end_date": farFuture,
441+
},
442+
},
443+
"users": []map[string]any{
444+
{"user_id": audiusUserID, "handle": "audius"},
445+
{"user_id": liveFollowHostID, "handle": "live_follow"},
446+
{"user_id": deletedFollowHostID, "handle": "deleted_follow"},
447+
{"user_id": staleFollowHostID, "handle": "stale_follow"},
448+
{"user_id": remixerID, "handle": "remixer"},
449+
},
450+
"follows": []map[string]any{
451+
{"follower_user_id": audiusUserID, "followee_user_id": liveFollowHostID},
452+
{"follower_user_id": audiusUserID, "followee_user_id": deletedFollowHostID, "is_delete": true},
453+
{"follower_user_id": audiusUserID, "followee_user_id": staleFollowHostID, "is_current": false},
454+
},
455+
"tracks": []map[string]any{
456+
{"track_id": liveTrackID, "owner_id": liveFollowHostID, "created_at": contestStart},
457+
{"track_id": deletedTrackID, "owner_id": deletedFollowHostID, "created_at": contestStart},
458+
{"track_id": staleTrackID, "owner_id": staleFollowHostID, "created_at": contestStart},
459+
// One entry per contest so all three reach the same entry_count.
460+
{"track_id": 8411, "owner_id": remixerID, "created_at": inWindow},
461+
{"track_id": 8412, "owner_id": remixerID, "created_at": inWindow},
462+
{"track_id": 8413, "owner_id": remixerID, "created_at": inWindow},
463+
},
464+
"remixes": []map[string]any{
465+
{"parent_track_id": liveTrackID, "child_track_id": 8411},
466+
{"parent_track_id": deletedTrackID, "child_track_id": 8412},
467+
{"parent_track_id": staleTrackID, "child_track_id": 8413},
468+
},
469+
}
470+
database.Seed(app.pool.Replicas[0], fixtures)
471+
472+
liveEvent := trashid.MustEncodeHashID(701)
473+
deletedEvent := trashid.MustEncodeHashID(702)
474+
staleEvent := trashid.MustEncodeHashID(703)
475+
476+
prev := config.Cfg.FeaturedAudienceUserID
477+
config.Cfg.FeaturedAudienceUserID = int32(audiusUserID)
478+
t.Cleanup(func() { config.Cfg.FeaturedAudienceUserID = prev })
479+
480+
status, body := testGet(t, app, "/v1/events/remix-contests")
481+
assert.Equal(t, 200, status)
482+
483+
// liveFollowHost should be the only one promoted to the followed sub-group.
484+
// The other two are unfollowed and break by event_id ASC (702 < 703).
485+
jsonAssert(t, body, map[string]any{
486+
"data.#": 3,
487+
"data.0.event_id": liveEvent, // 701 — only live follow row
488+
"data.1.event_id": deletedEvent, // 702 — follow soft-deleted, treated as unfollowed
489+
"data.2.event_id": staleEvent, // 703 — follow not current, treated as unfollowed
490+
})
491+
}
492+
360493
// TestRemixContestsExcludesUnavailableContent covers server-side filtering
361494
// of contests whose track or host is not in a publishable state. The
362495
// frontend used to drop these on the client (the "deleted accounts surface

config/config.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ type Config struct {
5858
AudiusApiSecret string
5959
// Shared secret for notifications-dashboard (or other internal jobs) to read notification campaign push open counts
6060
NotificationCampaignOpenMetricsSecret string
61-
// User id whose remix contests should sort first in the public contest list.
62-
// Zero (the default when the env var is unset) disables featured prioritization.
61+
// Audius account user id. In the public remix-contests list, hosts followed
62+
// by this account sort ahead of other hosts within both the open and ended
63+
// groups. Zero (the default when the env var is unset) disables follow-based
64+
// prioritization (the list reduces to open-before-ended).
6365
FeaturedAudienceUserID int32
6466
}
6567

0 commit comments

Comments
 (0)