Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions api/v1_events_remix_contests.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ type GetRemixContestsParams struct {

// v1EventsRemixContests returns remix-contest events from the events table.
// Sort priority:
// 1. Featured-audience-account contests (config.Cfg.FeaturedAudienceUserID; 0 disables).
// 2. Contests that have at least one entry.
// 3. Ended contests with zero entries land last.
// 1. Open contests whose host is followed by the Audius account.
// 2. Open contests whose host is not followed by the Audius account.
// 3. Ended contests whose host is followed by the Audius account.
// 4. Ended contests whose host is not followed by the Audius account.
//
// Within each group we keep the existing active-first / soonest-ending sort.
// The Audius account is config.Cfg.FeaturedAudienceUserID; 0 disables the
// follow-based tiebreak (groups collapse to open-vs-ended only).
// Within each group, results are sorted by entry count descending, then by
// the active-first / soonest-ending sort, then event_id ASC as a stable tiebreak.
// Supports pagination and an optional `status` filter (active | ended | all).
func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
params := GetRemixContestsParams{}
Expand Down Expand Up @@ -120,10 +124,23 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
) ec ON true
WHERE ` + strings.Join(filters, " AND ") + `
ORDER BY
CASE WHEN @featured_user_id::int4 != 0 AND e.user_id = @featured_user_id::int4 THEN 0 ELSE 1 END ASC,
CASE WHEN COALESCE(ec.entry_count, 0) > 0 THEN 0 ELSE 1 END ASC,
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,
-- Primary: open (0) before ended (1).
CASE WHEN e.end_date IS NULL OR e.end_date > NOW() THEN 0 ELSE 1 END ASC,
-- Secondary: Audius-followed hosts (0) before unfollowed (1).
-- When @audius_user_id is 0 the tiebreak collapses (all rows score 1).
CASE
WHEN @audius_user_id::int4 != 0
AND EXISTS (
SELECT 1 FROM follows f
WHERE f.follower_user_id = @audius_user_id::int4
AND f.followee_user_id = e.user_id
AND f.is_current = true
AND f.is_delete = false
) THEN 0
ELSE 1
END ASC,
-- Within each group, more entries first.
COALESCE(ec.entry_count, 0) DESC,
CASE WHEN e.end_date IS NULL OR e.end_date > NOW() THEN e.end_date END ASC NULLS LAST,
CASE WHEN e.end_date IS NOT NULL AND e.end_date <= NOW() THEN e.end_date END DESC,
e.event_id ASC
Expand All @@ -133,7 +150,7 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{
"limit": params.Limit,
"offset": params.Offset,
"featured_user_id": config.Cfg.FeaturedAudienceUserID,
"audius_user_id": config.Cfg.FeaturedAudienceUserID,
"karmaCommentCountThreshold": karmaCommentCountThreshold,
})
if err != nil {
Expand Down
261 changes: 197 additions & 64 deletions api/v1_events_remix_contests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,28 +227,31 @@ func TestRemixContestsDiscoveryPage(t *testing.T) {
})
}

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

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

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

farFuture := parseTime(t, "2099-01-01")
farPast := parseTime(t, "2024-02-10")
Expand All @@ -259,104 +262,234 @@ func TestRemixContestsSortPriority(t *testing.T) {
"events": []map[string]any{
{
"event_id": 601, "event_type": "remix_contest", "entity_type": "track",
"entity_id": featuredEndedZeroTrackID, "user_id": featuredHostID,
"created_at": contestStart, "end_date": farPast,
"entity_id": openFollowedHighEntriesTrackID, "user_id": followedHostID,
"created_at": contestStart, "end_date": farFuture,
},
{
"event_id": 602, "event_type": "remix_contest", "entity_type": "track",
"entity_id": hasEntriesActiveTrackID, "user_id": regularHostID,
"entity_id": openFollowedLowEntriesTrackID, "user_id": followedHostID,
"created_at": contestStart, "end_date": farFuture,
},
{
"event_id": 603, "event_type": "remix_contest", "entity_type": "track",
"entity_id": hasEntriesEndedTrackID, "user_id": regularHostID,
"created_at": contestStart, "end_date": farPast,
"entity_id": openUnfollowedHighTrackID, "user_id": unfollowedHostID,
"created_at": contestStart, "end_date": farFuture,
},
{
"event_id": 604, "event_type": "remix_contest", "entity_type": "track",
"entity_id": activeZeroTrackID, "user_id": regularHostID,
"entity_id": openUnfollowedLowTrackID, "user_id": unfollowedHostID,
"created_at": contestStart, "end_date": farFuture,
},
{
"event_id": 605, "event_type": "remix_contest", "entity_type": "track",
"entity_id": endedZeroTrackID, "user_id": regularHostID,
"entity_id": endedFollowedHighTrackID, "user_id": followedHostID,
"created_at": contestStart, "end_date": farPast,
},
{
"event_id": 606, "event_type": "remix_contest", "entity_type": "track",
"entity_id": endedFollowedLowTrackID, "user_id": followedHostID,
"created_at": contestStart, "end_date": farPast,
},
{
"event_id": 607, "event_type": "remix_contest", "entity_type": "track",
"entity_id": endedUnfollowedHighTrackID, "user_id": unfollowedHostID,
"created_at": contestStart, "end_date": farPast,
},
{
"event_id": 608, "event_type": "remix_contest", "entity_type": "track",
"entity_id": endedUnfollowedLowTrackID, "user_id": unfollowedHostID,
"created_at": contestStart, "end_date": farPast,
},
},
"users": []map[string]any{
{"user_id": featuredHostID, "handle": "featured"},
{"user_id": regularHostID, "handle": "regular"},
{"user_id": ownerID, "handle": "owner"},
{"user_id": audiusUserID, "handle": "audius"},
{"user_id": followedHostID, "handle": "followed"},
{"user_id": unfollowedHostID, "handle": "unfollowed"},
{"user_id": remixerID, "handle": "remixer"},
},
"follows": []map[string]any{
{"follower_user_id": audiusUserID, "followee_user_id": followedHostID},
},
"tracks": []map[string]any{
{"track_id": featuredEndedZeroTrackID, "owner_id": featuredHostID, "created_at": contestStart},
{"track_id": hasEntriesActiveTrackID, "owner_id": regularHostID, "created_at": contestStart},
{"track_id": hasEntriesEndedTrackID, "owner_id": regularHostID, "created_at": contestStart},
{"track_id": activeZeroTrackID, "owner_id": regularHostID, "created_at": contestStart},
{"track_id": endedZeroTrackID, "owner_id": regularHostID, "created_at": contestStart},
// Entries — only for the two has-entries contests.
{"track_id": 8302, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8303, "owner_id": remixerID, "created_at": inWindow},
{"track_id": openFollowedHighEntriesTrackID, "owner_id": followedHostID, "created_at": contestStart},
{"track_id": openFollowedLowEntriesTrackID, "owner_id": followedHostID, "created_at": contestStart},
{"track_id": openUnfollowedHighTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
{"track_id": openUnfollowedLowTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
{"track_id": endedFollowedHighTrackID, "owner_id": followedHostID, "created_at": contestStart},
{"track_id": endedFollowedLowTrackID, "owner_id": followedHostID, "created_at": contestStart},
{"track_id": endedUnfollowedHighTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
{"track_id": endedUnfollowedLowTrackID, "owner_id": unfollowedHostID, "created_at": contestStart},
// Two entries each for the "high" contest in every group.
{"track_id": 8311, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8312, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8313, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8314, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8315, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8316, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8317, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8318, "owner_id": remixerID, "created_at": inWindow},
},
"remixes": []map[string]any{
{"parent_track_id": hasEntriesActiveTrackID, "child_track_id": 8302},
{"parent_track_id": hasEntriesEndedTrackID, "child_track_id": 8303},
{"parent_track_id": openFollowedHighEntriesTrackID, "child_track_id": 8311},
{"parent_track_id": openFollowedHighEntriesTrackID, "child_track_id": 8312},
{"parent_track_id": openUnfollowedHighTrackID, "child_track_id": 8313},
{"parent_track_id": openUnfollowedHighTrackID, "child_track_id": 8314},
{"parent_track_id": endedFollowedHighTrackID, "child_track_id": 8315},
{"parent_track_id": endedFollowedHighTrackID, "child_track_id": 8316},
{"parent_track_id": endedUnfollowedHighTrackID, "child_track_id": 8317},
{"parent_track_id": endedUnfollowedHighTrackID, "child_track_id": 8318},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

featuredEvent := trashid.MustEncodeHashID(601)
hasActiveEvent := trashid.MustEncodeHashID(602)
hasEndedEvent := trashid.MustEncodeHashID(603)
activeZeroEvent := trashid.MustEncodeHashID(604)
endedZeroEvent := trashid.MustEncodeHashID(605)
openFollowedHigh := trashid.MustEncodeHashID(601)
openFollowedLow := trashid.MustEncodeHashID(602)
openUnfollowedHigh := trashid.MustEncodeHashID(603)
openUnfollowedLow := trashid.MustEncodeHashID(604)
endedFollowedHigh := trashid.MustEncodeHashID(605)
endedFollowedLow := trashid.MustEncodeHashID(606)
endedUnfollowedHigh := trashid.MustEncodeHashID(607)
endedUnfollowedLow := trashid.MustEncodeHashID(608)

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

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

jsonAssert(t, body, map[string]any{
"data.#": 5,
"data.0.event_id": featuredEvent, // featured (group 1)
"data.1.event_id": hasActiveEvent, // has entries, active (group 2)
"data.2.event_id": hasEndedEvent, // has entries, ended (group 2)
"data.3.event_id": activeZeroEvent, // active, zero entries (group 3 — not ended-empty)
"data.4.event_id": endedZeroEvent, // ended + zero entries (group 4, LAST)
"data.#": 8,
"data.0.event_id": openFollowedHigh, // group 1, 2 entries
"data.1.event_id": openFollowedLow, // group 1, 0 entries
"data.2.event_id": openUnfollowedHigh, // group 2, 2 entries
"data.3.event_id": openUnfollowedLow, // group 2, 0 entries
"data.4.event_id": endedFollowedHigh, // group 3, 2 entries
"data.5.event_id": endedFollowedLow, // group 3, 0 entries
"data.6.event_id": endedUnfollowedHigh, // group 4, 2 entries
"data.7.event_id": endedUnfollowedLow, // group 4, 0 entries
})
})

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

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

// featuredEvent is now ended-with-zero-entries, so it should sort
// alongside endedZeroEvent at the bottom (group 4). The two has-entries
// contests are group 2, activeZeroEvent is group 3.
// Without the Audius user, the follow-based tiebreak collapses. The
// remaining order is: open (4 rows) → ended (4 rows), each block sorted
// by entry_count DESC then event_id ASC.
jsonAssert(t, body, map[string]any{
"data.#": 5,
"data.0.event_id": hasActiveEvent,
"data.1.event_id": hasEndedEvent,
"data.2.event_id": activeZeroEvent,
})
// Last two entries are both ended-zero-entries — order within is
// determined by end_date DESC then event_id; both events share end_date
// (farPast), so the smaller event_id (601) comes first.
jsonAssert(t, body, map[string]any{
"data.3.event_id": featuredEvent,
"data.4.event_id": endedZeroEvent,
"data.#": 8,
// Open contests with 2 entries (601, 603), then with 0 (602, 604).
"data.0.event_id": openFollowedHigh, // 601, 2 entries
"data.1.event_id": openUnfollowedHigh, // 603, 2 entries
"data.2.event_id": openFollowedLow, // 602, 0 entries
"data.3.event_id": openUnfollowedLow, // 604, 0 entries
// Ended contests with 2 entries (605, 607), then with 0 (606, 608).
"data.4.event_id": endedFollowedHigh, // 605
"data.5.event_id": endedUnfollowedHigh, // 607
"data.6.event_id": endedFollowedLow, // 606
"data.7.event_id": endedUnfollowedLow, // 608
})
})
}

// TestRemixContestsFollowFilterIgnoresStaleRows verifies that the
// follow-based tiebreak only counts live `follows` rows. A soft-deleted
// (is_delete=true) or non-current (is_current=false) follow must not promote
// a host into the "followed" sub-group.
func TestRemixContestsFollowFilterIgnoresStaleRows(t *testing.T) {
app := emptyTestApp(t)

audiusUserID := 9200
liveFollowHostID := 9201
deletedFollowHostID := 9202
staleFollowHostID := 9203
remixerID := 9210

liveTrackID := 8401
deletedTrackID := 8402
staleTrackID := 8403

farFuture := parseTime(t, "2099-01-01")
contestStart := parseTime(t, "2024-01-02")
inWindow := parseTime(t, "2024-01-03")

fixtures := database.FixtureMap{
"events": []map[string]any{
// All three contests are open with the same entry_count (1), so the
// only differentiator left is the follow tiebreak.
{
"event_id": 701, "event_type": "remix_contest", "entity_type": "track",
"entity_id": liveTrackID, "user_id": liveFollowHostID,
"created_at": contestStart, "end_date": farFuture,
},
{
"event_id": 702, "event_type": "remix_contest", "entity_type": "track",
"entity_id": deletedTrackID, "user_id": deletedFollowHostID,
"created_at": contestStart, "end_date": farFuture,
},
{
"event_id": 703, "event_type": "remix_contest", "entity_type": "track",
"entity_id": staleTrackID, "user_id": staleFollowHostID,
"created_at": contestStart, "end_date": farFuture,
},
},
"users": []map[string]any{
{"user_id": audiusUserID, "handle": "audius"},
{"user_id": liveFollowHostID, "handle": "live_follow"},
{"user_id": deletedFollowHostID, "handle": "deleted_follow"},
{"user_id": staleFollowHostID, "handle": "stale_follow"},
{"user_id": remixerID, "handle": "remixer"},
},
"follows": []map[string]any{
{"follower_user_id": audiusUserID, "followee_user_id": liveFollowHostID},
{"follower_user_id": audiusUserID, "followee_user_id": deletedFollowHostID, "is_delete": true},
{"follower_user_id": audiusUserID, "followee_user_id": staleFollowHostID, "is_current": false},
},
"tracks": []map[string]any{
{"track_id": liveTrackID, "owner_id": liveFollowHostID, "created_at": contestStart},
{"track_id": deletedTrackID, "owner_id": deletedFollowHostID, "created_at": contestStart},
{"track_id": staleTrackID, "owner_id": staleFollowHostID, "created_at": contestStart},
// One entry per contest so all three reach the same entry_count.
{"track_id": 8411, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8412, "owner_id": remixerID, "created_at": inWindow},
{"track_id": 8413, "owner_id": remixerID, "created_at": inWindow},
},
"remixes": []map[string]any{
{"parent_track_id": liveTrackID, "child_track_id": 8411},
{"parent_track_id": deletedTrackID, "child_track_id": 8412},
{"parent_track_id": staleTrackID, "child_track_id": 8413},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

liveEvent := trashid.MustEncodeHashID(701)
deletedEvent := trashid.MustEncodeHashID(702)
staleEvent := trashid.MustEncodeHashID(703)

prev := config.Cfg.FeaturedAudienceUserID
config.Cfg.FeaturedAudienceUserID = int32(audiusUserID)
t.Cleanup(func() { config.Cfg.FeaturedAudienceUserID = prev })

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

// liveFollowHost should be the only one promoted to the followed sub-group.
// The other two are unfollowed and break by event_id ASC (702 < 703).
jsonAssert(t, body, map[string]any{
"data.#": 3,
"data.0.event_id": liveEvent, // 701 — only live follow row
"data.1.event_id": deletedEvent, // 702 — follow soft-deleted, treated as unfollowed
"data.2.event_id": staleEvent, // 703 — follow not current, treated as unfollowed
})
}

// TestRemixContestsExcludesUnavailableContent covers server-side filtering
// of contests whose track or host is not in a publishable state. The
// frontend used to drop these on the client (the "deleted accounts surface
Expand Down
6 changes: 4 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ type Config struct {
AudiusApiSecret string
// Shared secret for notifications-dashboard (or other internal jobs) to read notification campaign push open counts
NotificationCampaignOpenMetricsSecret string
// User id whose remix contests should sort first in the public contest list.
// Zero (the default when the env var is unset) disables featured prioritization.
// Audius account user id. In the public remix-contests list, hosts followed
// by this account sort ahead of other hosts within both the open and ended
// groups. Zero (the default when the env var is unset) disables follow-based
// prioritization (the list reduces to open-before-ended).
FeaturedAudienceUserID int32
}

Expand Down
Loading