diff --git a/api/v1_event_comments.go b/api/v1_event_comments.go index 7d4dff2d..efb2d107 100644 --- a/api/v1_event_comments.go +++ b/api/v1_event_comments.go @@ -50,6 +50,9 @@ func (app *ApiServer) v1EventComments(c *fiber.Ctx) error { // Pull top-level comment ids for this event, sorted and paginated the same // way track comments are. Threads are materialised below by FullComments. + // The shadowban filtering mirrors v1_track_comments: high-karma reports, + // per-viewer mutes, the event owner's mutes, low abuse scores, and + // deactivated users all hide comments here too. orderBy := `comments.created_at DESC` switch params.SortMethod { case "timestamp": @@ -59,22 +62,81 @@ func (app *ApiServer) v1EventComments(c *fiber.Ctx) error { } sql := ` + WITH + muted_by_karma AS ( + SELECT muted_user_id + FROM muted_users + JOIN aggregate_user ON muted_users.user_id = aggregate_user.user_id + WHERE muted_users.is_delete = false + GROUP BY muted_user_id + HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold + ), + low_abuse_score AS ( + SELECT user_id FROM aggregate_user WHERE score < 0 + ), + deactivated_users AS ( + SELECT user_id FROM users WHERE is_deactivated = true + ), + high_karma_reporters AS ( + SELECT comment_reports.comment_id + FROM comment_reports + JOIN aggregate_user ON comment_reports.user_id = aggregate_user.user_id + WHERE comment_reports.is_delete = false + GROUP BY comment_reports.comment_id + HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold + ) + SELECT comments.comment_id FROM comments LEFT JOIN comment_threads ct ON ct.comment_id = comments.comment_id + LEFT JOIN comment_reports ON comments.comment_id = comment_reports.comment_id + LEFT JOIN muted_users ON ( + muted_users.muted_user_id = comments.user_id + AND ( + muted_users.user_id = @myId + OR muted_users.user_id = @eventOwnerId + OR muted_users.muted_user_id IN (SELECT muted_user_id FROM muted_by_karma) + ) + AND @myId != comments.user_id + ) + LEFT JOIN low_abuse_score ON ( + low_abuse_score.user_id = comments.user_id + AND @myId != comments.user_id + AND @eventOwnerId != comments.user_id + ) + LEFT JOIN deactivated_users ON ( + deactivated_users.user_id = comments.user_id + AND @myId != comments.user_id + AND @eventOwnerId != comments.user_id + ) WHERE comments.entity_type = 'Event' AND comments.entity_id = @eventId AND comments.is_delete = false AND ct.parent_comment_id IS NULL + AND ( + comment_reports.comment_id IS NULL + OR ( + comment_reports.user_id != COALESCE(@myId, 0) + AND comment_reports.user_id != @eventOwnerId + AND comments.comment_id NOT IN (SELECT hkr.comment_id FROM high_karma_reporters hkr) + ) + OR comment_reports.is_delete = true + ) + AND (muted_users.muted_user_id IS NULL OR muted_users.is_delete = true) + AND low_abuse_score.user_id IS NULL + AND deactivated_users.user_id IS NULL ORDER BY ` + orderBy + ` LIMIT @limit OFFSET @offset ` args := pgx.NamedArgs{ - "eventId": eventID, - "limit": params.Limit, - "offset": params.Offset, + "eventId": eventID, + "eventOwnerId": eventRow.UserID, + "myId": myID, + "karmaCommentCountThreshold": karmaCommentCountThreshold, + "limit": params.Limit, + "offset": params.Offset, } rows, err := app.pool.Query(c.Context(), sql, args) diff --git a/api/v1_event_comments_test.go b/api/v1_event_comments_test.go index 55e8dc4a..15dc946a 100644 --- a/api/v1_event_comments_test.go +++ b/api/v1_event_comments_test.go @@ -121,14 +121,18 @@ func TestEventComments_ReturnsTopLevelOnlyNewestFirst(t *testing.T) { // Default sort is newest-first, so 601 (Jun 2) should come before 600 (Jun 1). // The reply (602) must NOT appear as its own top-level item. + // The reply nested under 600 must also expose `parent_comment_id` — without + // this field the mobile/web row classifier mis-routes artist replies to the + // "Updates" feed instead of threading them under the parent comment. jsonAssert(t, body, map[string]any{ - "data.#": 2, - "data.0.id": enc601, - "data.0.message": "thanks for joining", - "data.1.id": enc600, - "data.1.message": "first!", - "event_user_id": encUser1, - "data.1.replies.#": 1, + "data.#": 2, + "data.0.id": enc601, + "data.0.message": "thanks for joining", + "data.1.id": enc600, + "data.1.message": "first!", + "event_user_id": encUser1, + "data.1.replies.#": 1, + "data.1.replies.0.parent_comment_id": float64(600), }) } @@ -296,6 +300,91 @@ func TestEventComments_PaginationAndTimestampSort(t *testing.T) { }) } +// TestEventComments_FiltersDeactivatedAuthor regression-tests that the +// event-comment shadowban list (added in parity with v1_track_comments) hides +// comments authored by deactivated users. Without this filter a globally +// shadowbanned account could re-emerge on contest pages. +func TestEventComments_FiltersDeactivatedAuthor(t *testing.T) { + app := emptyTestApp(t) + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": []map[string]any{ + { + "user_id": 1, + "handle": "eventartist", + "handle_lc": "eventartist", + "name": "Event Artist", + "wallet": "0xe0f1230000000000000000000000000000000001", + }, + { + "user_id": 2, + "handle": "eventfan", + "handle_lc": "eventfan", + "name": "Event Fan", + "wallet": "0xe0f1230000000000000000000000000000000002", + }, + { + "user_id": 3, + "handle": "eventbanned", + "handle_lc": "eventbanned", + "name": "Event Banned", + "wallet": "0xe0f1230000000000000000000000000000000003", + "is_deactivated": true, + }, + }, + "tracks": { + { + "track_id": 1, + "owner_id": 1, + "title": "Original", + "created_at": "2020-01-01 00:00:00", + }, + }, + "events": { + { + "event_id": 1100, + "event_type": "remix_contest", + "user_id": 1, + "entity_type": "track", + "entity_id": 1, + "event_data": map[string]any{"description": "remix me"}, + "created_at": "2020-05-01 00:00:00", + "updated_at": "2020-05-01 00:00:00", + }, + }, + "comments": []map[string]any{ + { + "comment_id": 1100, + "user_id": 2, + "entity_id": 1100, + "entity_type": "Event", + "text": "great contest", + "created_at": "2020-06-01 00:00:00", + }, + { + "comment_id": 1101, + "user_id": 3, + "entity_id": 1100, + "entity_type": "Event", + "text": "i am deactivated", + "created_at": "2020-06-02 00:00:00", + }, + }, + }) + + encEvent, err := trashid.EncodeHashId(1100) + require.NoError(t, err) + + status, body := testGet(t, app, "/v1/events/"+encEvent+"/comments") + require.Equal(t, 200, status, string(body)) + + // The deactivated user's comment is filtered out; only the fan's row remains. + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.message": "great contest", + }) +} + func TestEventComments_DoesNotLeakTrackComments(t *testing.T) { app := emptyTestApp(t) diff --git a/api/v1_events_remix_contests.go b/api/v1_events_remix_contests.go index 1da935f9..4f79bcee 100644 --- a/api/v1_events_remix_contests.go +++ b/api/v1_events_remix_contests.go @@ -4,6 +4,7 @@ import ( "strings" "api.audius.co/api/dbv1" + "api.audius.co/config" "api.audius.co/trashid" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" @@ -15,10 +16,14 @@ type GetRemixContestsParams struct { Status string `query:"status" default:"all" validate:"oneof=active ended all"` } -// v1EventsRemixContests returns remix-contest events from the events table, -// ordered with currently-active contests first (by soonest-ending end_date), -// followed by ended contests (most-recently-ended first). Supports pagination -// and an optional `status` filter (active | ended | all). +// 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. +// +// Within each group we keep the existing active-first / soonest-ending sort. +// Supports pagination and an optional `status` filter (active | ended | all). func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { params := GetRemixContestsParams{} if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { @@ -28,7 +33,9 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { filters := []string{ "e.event_type = 'remix_contest'", "e.is_deleted = false", - "(e.entity_type != 'track' OR (t.track_id IS NOT NULL AND t.is_delete = false))", + "u.is_deactivated = false", + "u.is_available = true", + "(e.entity_type != 'track' OR (t.track_id IS NOT NULL AND t.is_delete = false AND t.is_unlisted = false))", } switch params.Status { @@ -38,6 +45,9 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { filters = append(filters, "(e.end_date IS NOT NULL AND e.end_date <= NOW())") } + // LATERAL subquery mirrors the entry-count filter used in v1TrackRemixes + // (only_contest_entries=true): a child track is an entry iff it was created + // after the contest started, before its end_date, and is currently listed. sql := ` SELECT e.event_id, @@ -49,14 +59,31 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { e.is_deleted, e.created_at, e.updated_at, - e.event_data + e.event_data, + COALESCE(ec.entry_count, 0) AS entry_count FROM events e + JOIN users u ON u.user_id = e.user_id + AND u.is_current = true LEFT JOIN tracks t ON t.track_id = e.entity_id AND t.is_current = true AND e.entity_type = 'track' AND t.access_authorities IS NULL + LEFT JOIN LATERAL ( + SELECT COUNT(DISTINCT ct.track_id) AS entry_count + FROM remixes rm + JOIN tracks ct ON ct.track_id = rm.child_track_id + WHERE rm.parent_track_id = e.entity_id + AND ct.is_current = true + AND ct.is_delete = false + AND ct.is_unlisted = false + AND ct.created_at > e.created_at + AND (e.end_date IS NULL OR ct.created_at < e.end_date) + ) 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, CASE WHEN e.end_date IS NULL OR e.end_date > NOW() THEN 0 ELSE 1 END ASC, 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, @@ -65,8 +92,9 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { ` rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ - "limit": params.Limit, - "offset": params.Offset, + "limit": params.Limit, + "offset": params.Offset, + "featured_user_id": config.Cfg.FeaturedAudienceUserID, }) if err != nil { return err @@ -74,8 +102,10 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { defer rows.Close() var items []dbv1.GetEventsRow + entryCounts := map[string]int64{} for rows.Next() { var row dbv1.GetEventsRow + var entryCount int64 if err := rows.Scan( &row.EventID, &row.EntityType, @@ -87,10 +117,14 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { &row.CreatedAt, &row.UpdatedAt, &row.EventData, + &entryCount, ); err != nil { return err } items = append(items, row) + if row.EntityType == dbv1.EventEntityTypeTrack && row.EntityID.Valid { + entryCounts[trashid.MustEncodeHashID(int(row.EntityID.Int32))] = entryCount + } } if err := rows.Err(); err != nil { return err @@ -154,56 +188,15 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { tracks = append(tracks, t) } - // Per-contest entry counts. Mirrors the filter used in - // v1TrackRemixes when only_contest_entries=true: a remix is an entry iff - // the child track was created *after* the contest started and *before* - // its end_date, and the child track is listed + published. Keyed by the - // contest's parent track id (event.entity_id) so the UI can prime the + // `entry_counts` was populated alongside the main query above (keyed by the + // contest's parent track hashid) so the UI can prime the // `useRemixes({ trackId, isContestEntry: true })` cache directly. - entryCounts := map[string]int64{} - if len(trackIDs) > 0 { - countRows, err := app.pool.Query(c.Context(), ` - SELECT - e.entity_id, - COUNT(DISTINCT t.track_id) FILTER ( - WHERE t.is_current = true - AND t.is_delete = false - AND t.is_unlisted = false - AND t.created_at > e.created_at - AND (e.end_date IS NULL OR t.created_at < e.end_date) - ) AS entry_count - FROM events e - LEFT JOIN remixes rm ON rm.parent_track_id = e.entity_id - LEFT JOIN tracks t ON t.track_id = rm.child_track_id - WHERE e.event_type = 'remix_contest' - AND e.is_deleted = false - AND e.entity_type = 'track' - AND e.entity_id = ANY(@track_ids) - GROUP BY e.entity_id - `, pgx.NamedArgs{"track_ids": trackIDs}) - if err != nil { - return err - } - defer countRows.Close() - for countRows.Next() { - var parentTrackID int32 - var count int64 - if err := countRows.Scan(&parentTrackID, &count); err != nil { - return err - } - entryCounts[trashid.MustEncodeHashID(int(parentTrackID))] = count - } - if err := countRows.Err(); err != nil { - return err - } - } - return c.JSON(fiber.Map{ "data": data, "related": fiber.Map{ - "users": users, - "tracks": tracks, - "entry_counts": entryCounts, + "users": users, + "tracks": tracks, + "entry_counts": entryCounts, }, }) } diff --git a/api/v1_events_remix_contests_test.go b/api/v1_events_remix_contests_test.go index cf9c5b41..fabd685a 100644 --- a/api/v1_events_remix_contests_test.go +++ b/api/v1_events_remix_contests_test.go @@ -3,6 +3,7 @@ package api import ( "testing" + "api.audius.co/config" "api.audius.co/database" "api.audius.co/trashid" "github.com/stretchr/testify/assert" @@ -224,3 +225,238 @@ 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. +// +// Within each group the existing active-first / soonest-ending sort still +// applies — we don't reassert that here because TestRemixContestsDiscoveryPage +// already covers it. +func TestRemixContestsSortPriority(t *testing.T) { + app := emptyTestApp(t) + + featuredHostID := 9101 // contests by this user must sort first + regularHostID := 9102 + ownerID := 9103 + 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 + + farFuture := parseTime(t, "2099-01-01") + farPast := parseTime(t, "2024-02-10") + contestStart := parseTime(t, "2024-01-02") + inWindow := parseTime(t, "2024-01-03") + + fixtures := database.FixtureMap{ + "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, + }, + { + "event_id": 602, "event_type": "remix_contest", "entity_type": "track", + "entity_id": hasEntriesActiveTrackID, "user_id": regularHostID, + "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, + }, + { + "event_id": 604, "event_type": "remix_contest", "entity_type": "track", + "entity_id": activeZeroTrackID, "user_id": regularHostID, + "created_at": contestStart, "end_date": farFuture, + }, + { + "event_id": 605, "event_type": "remix_contest", "entity_type": "track", + "entity_id": endedZeroTrackID, "user_id": regularHostID, + "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": remixerID, "handle": "remixer"}, + }, + "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}, + }, + "remixes": []map[string]any{ + {"parent_track_id": hasEntriesActiveTrackID, "child_track_id": 8302}, + {"parent_track_id": hasEntriesEndedTrackID, "child_track_id": 8303}, + }, + } + 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) + + t.Run("featured account contests sort first, ended-zero-entries last", func(t *testing.T) { + prev := config.Cfg.FeaturedAudienceUserID + config.Cfg.FeaturedAudienceUserID = int32(featuredHostID) + 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) + }) + }) + + t.Run("with featured user unset, featured contest falls back to entry-based sort", 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. + 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, + }) + }) +} + +// 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 +// contests" workaround in useAllRemixContests); the backend is now the +// source of truth. +func TestRemixContestsExcludesUnavailableContent(t *testing.T) { + app := emptyTestApp(t) + + activeHostID := 9501 + deactivatedHostID := 9502 + unavailableHostID := 9503 + + visibleTrackID := 8501 + deletedTrackID := 8502 + unlistedTrackID := 8503 + deactivatedHostTrackID := 8504 + unavailableHostTrackID := 8505 + + start := parseTime(t, "2024-01-02") + end := parseTime(t, "2099-01-01") + + fixtures := database.FixtureMap{ + "events": []map[string]any{ + { + "event_id": 701, "event_type": "remix_contest", "entity_type": "track", + "entity_id": visibleTrackID, "user_id": activeHostID, + "created_at": start, "end_date": end, + }, + { + "event_id": 702, "event_type": "remix_contest", "entity_type": "track", + "entity_id": deletedTrackID, "user_id": activeHostID, + "created_at": start, "end_date": end, + }, + { + "event_id": 703, "event_type": "remix_contest", "entity_type": "track", + "entity_id": unlistedTrackID, "user_id": activeHostID, + "created_at": start, "end_date": end, + }, + { + "event_id": 704, "event_type": "remix_contest", "entity_type": "track", + "entity_id": deactivatedHostTrackID, "user_id": deactivatedHostID, + "created_at": start, "end_date": end, + }, + { + "event_id": 705, "event_type": "remix_contest", "entity_type": "track", + "entity_id": unavailableHostTrackID, "user_id": unavailableHostID, + "created_at": start, "end_date": end, + }, + }, + "users": []map[string]any{ + {"user_id": activeHostID, "handle": "active_host"}, + {"user_id": deactivatedHostID, "handle": "deactivated_host", "is_deactivated": true}, + {"user_id": unavailableHostID, "handle": "unavailable_host", "is_available": false}, + }, + "tracks": []map[string]any{ + {"track_id": visibleTrackID, "owner_id": activeHostID, "created_at": start}, + {"track_id": deletedTrackID, "owner_id": activeHostID, "created_at": start, "is_delete": true}, + {"track_id": unlistedTrackID, "owner_id": activeHostID, "created_at": start, "is_unlisted": true}, + {"track_id": deactivatedHostTrackID, "owner_id": deactivatedHostID, "created_at": start}, + {"track_id": unavailableHostTrackID, "owner_id": unavailableHostID, "created_at": start}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + visibleEventHash := trashid.MustEncodeHashID(701) + + t.Run("only the contest with a visible track and active host is returned", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": visibleEventHash, + }) + }) + + t.Run("deleted track contest is excluded", func(t *testing.T) { + _, body := testGet(t, app, "/v1/events/remix-contests") + eventIds := pluckStrings(body, "data.#.event_id") + assert.NotContains(t, eventIds, trashid.MustEncodeHashID(702), + "contest pointing at a deleted track must not be returned") + }) + + t.Run("unlisted track contest is excluded", func(t *testing.T) { + _, body := testGet(t, app, "/v1/events/remix-contests") + eventIds := pluckStrings(body, "data.#.event_id") + assert.NotContains(t, eventIds, trashid.MustEncodeHashID(703), + "contest pointing at an unlisted track must not be returned") + }) + + t.Run("deactivated host contest is excluded", func(t *testing.T) { + _, body := testGet(t, app, "/v1/events/remix-contests") + eventIds := pluckStrings(body, "data.#.event_id") + assert.NotContains(t, eventIds, trashid.MustEncodeHashID(704), + "contest hosted by a deactivated user must not be returned") + }) + + t.Run("unavailable (deleted) host contest is excluded", func(t *testing.T) { + _, body := testGet(t, app, "/v1/events/remix-contests") + eventIds := pluckStrings(body, "data.#.event_id") + assert.NotContains(t, eventIds, trashid.MustEncodeHashID(705), + "contest hosted by a user with is_available=false must not be returned") + }) +} diff --git a/config/config.go b/config/config.go index 29cbe594..29119b8d 100644 --- a/config/config.go +++ b/config/config.go @@ -58,6 +58,9 @@ 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. + FeaturedAudienceUserID int32 } var Cfg = Config{ @@ -272,4 +275,12 @@ func init() { if v := os.Getenv("archiverNodes"); v != "" { Cfg.ArchiverNodes = strings.Split(v, ",") } + + if v := os.Getenv("featuredAudienceUserId"); v != "" { + parsed, err := strconv.ParseInt(v, 10, 32) + if err != nil { + log.Fatalf("Invalid featuredAudienceUserId: %s", err) + } + Cfg.FeaturedAudienceUserID = int32(parsed) + } }