Skip to content

Commit 386ac75

Browse files
dylanjeffersclaude
andauthored
contests: exclude shadow-banned hosts from /v1/events/remix-contests (#803)
## Summary `v1EventsRemixContests` ([api/v1_events_remix_contests.go](api/v1_events_remix_contests.go)) — the endpoint backing the contests discovery page on mobile + web — previously surfaced contests whose host was shadow-banned. `v1EventComments` already applies the two-signal shadow-ban filter to comment authors; this PR mirrors the exact same pair against contest hosts so the discovery list and comment list stay in lockstep. This was originally drafted against \`packages/discovery-provider/src/queries/get_events.py\` in the apps monorepo (PR AudiusProject/apps#14297), but that Flask API was removed in #14236 and the file is dead code on main. Reopening here in the correct repo. ## The two signals | Signal | What it catches | Source pattern | |---|---|---| | `aggregate_user.score < 0` (`low_abuse_score` CTE) | bots, Audius-impersonators, fast-challenge-runners, low-engagement accounts | Same CTE used at [v1_event_comments.go:74-76](api/v1_event_comments.go#L74) | | `muted_by_karma` CTE | hosts muted by users whose combined `follower_count` crosses `karmaCommentCountThreshold` | Same CTE used at [v1_event_comments.go:66-73](api/v1_event_comments.go#L66) | Both CTEs are lifted verbatim from `v1_event_comments.go` so the filter is byte-for-byte identical to what the comment system applies to comment authors. Reuses the existing `karmaCommentCountThreshold` constant (defined at [v1_track_comment_count.go:8](api/v1_track_comment_count.go#L8)). ## Implementation Added two CTEs at the top of the SQL, two `NOT IN` filters to the existing `filters` slice, and bound the threshold constant: ```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 ) SELECT ... WHERE ... AND e.user_id NOT IN (SELECT user_id FROM low_abuse_score) AND e.user_id NOT IN (SELECT muted_user_id FROM muted_by_karma) ``` - The existing `u.is_deactivated = false` and `u.is_available = true` filters stay in place. Shadow-ban filtering layers on top. - The contest's parent track filter (`e.entity_type != 'track' OR ...`) is untouched. - The sort priority, pagination, status filter, and `entry_counts` LATERAL subquery are untouched. - The `users` and `tracks` related lookups downstream are unaffected — they just see fewer rows. ## Tests New `TestRemixContestsExcludesShadowbannedHosts` test follows the exact pattern of `TestRemixContestsExcludesUnavailableContent` already in this file. Seeds three contests: - clean host (score=0, no mutes) - low-score host (score=-1) - karma-muted host (muted by a high-follower user crossing the threshold) Three sub-assertions: only the clean contest is returned; low-score contest absent; karma-muted contest absent. \`go build ./api/...\` and \`go vet ./api/...\` both clean locally. Integration test couldn't be run end-to-end without a local Postgres at port 21300, but the test compiles fine and CI will run it against a fresh DB. ## Test plan - [ ] CI green on `go test ./api/...` - [ ] Manual smoke after deploy: hit `/v1/events/remix-contests` on staging, confirm a known shadow-banned account's contest no longer appears in the response - [ ] Confirm `useAllRemixContests` on mobile + web still returns the expected (non-shadowbanned) contests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 921fe28 commit 386ac75

2 files changed

Lines changed: 129 additions & 3 deletions

File tree

api/v1_events_remix_contests.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
3636
"u.is_deactivated = false",
3737
"u.is_available = true",
3838
"(e.entity_type != 'track' OR (t.track_id IS NOT NULL AND t.is_delete = false AND t.is_unlisted = false))",
39+
// Shadow-ban filters — mirror what v1_event_comments.go applies to
40+
// comment authors. Two parallel signals so the filter catches the
41+
// full population: low-quality / impersonator / bot accounts via
42+
// `aggregate_user.score < 0`, and community-flagged users via the
43+
// karma-muted set (sum of muters' follower_count crosses the
44+
// karmaCommentCountThreshold). Hosts in either bucket disappear
45+
// from the discovery list.
46+
"e.user_id NOT IN (SELECT user_id FROM low_abuse_score)",
47+
"e.user_id NOT IN (SELECT muted_user_id FROM muted_by_karma)",
3948
}
4049

4150
switch params.Status {
@@ -49,6 +58,18 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
4958
// (only_contest_entries=true): a child track is an entry iff it was created
5059
// after the contest started, before its end_date, and is currently listed.
5160
sql := `
61+
WITH
62+
muted_by_karma AS (
63+
SELECT muted_user_id
64+
FROM muted_users
65+
JOIN aggregate_user ON muted_users.user_id = aggregate_user.user_id
66+
WHERE muted_users.is_delete = false
67+
GROUP BY muted_user_id
68+
HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold
69+
),
70+
low_abuse_score AS (
71+
SELECT user_id FROM aggregate_user WHERE score < 0
72+
)
5273
SELECT
5374
e.event_id,
5475
e.entity_type::event_entity_type AS entity_type,
@@ -92,9 +113,10 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
92113
`
93114

94115
rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{
95-
"limit": params.Limit,
96-
"offset": params.Offset,
97-
"featured_user_id": config.Cfg.FeaturedAudienceUserID,
116+
"limit": params.Limit,
117+
"offset": params.Offset,
118+
"featured_user_id": config.Cfg.FeaturedAudienceUserID,
119+
"karmaCommentCountThreshold": karmaCommentCountThreshold,
98120
})
99121
if err != nil {
100122
return err

api/v1_events_remix_contests_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"context"
45
"testing"
56

67
"api.audius.co/config"
@@ -460,3 +461,106 @@ func TestRemixContestsExcludesUnavailableContent(t *testing.T) {
460461
"contest hosted by a user with is_available=false must not be returned")
461462
})
462463
}
464+
465+
// TestRemixContestsExcludesShadowbannedHosts covers the two parallel
466+
// shadow-ban signals applied to the discovery list:
467+
// 1. `aggregate_user.score < 0` (account-quality signal — bots,
468+
// impersonators, fast-challenge runners).
469+
// 2. The karma-muted set — host has been muted by users whose combined
470+
// follower_count crosses karmaCommentCountThreshold (community-driven
471+
// signal). Same shape used in v1_event_comments for comment authors.
472+
func TestRemixContestsExcludesShadowbannedHosts(t *testing.T) {
473+
app := emptyTestApp(t)
474+
475+
cleanHostID := 9601
476+
lowScoreHostID := 9602
477+
karmaMutedHostID := 9603
478+
highKarmaMuterID := 9604
479+
480+
cleanTrackID := 8601
481+
lowScoreTrackID := 8602
482+
karmaMutedTrackID := 8603
483+
484+
start := parseTime(t, "2024-01-02")
485+
end := parseTime(t, "2099-01-01")
486+
487+
fixtures := database.FixtureMap{
488+
"events": []map[string]any{
489+
{
490+
"event_id": 801, "event_type": "remix_contest", "entity_type": "track",
491+
"entity_id": cleanTrackID, "user_id": cleanHostID,
492+
"created_at": start, "end_date": end,
493+
},
494+
{
495+
"event_id": 802, "event_type": "remix_contest", "entity_type": "track",
496+
"entity_id": lowScoreTrackID, "user_id": lowScoreHostID,
497+
"created_at": start, "end_date": end,
498+
},
499+
{
500+
"event_id": 803, "event_type": "remix_contest", "entity_type": "track",
501+
"entity_id": karmaMutedTrackID, "user_id": karmaMutedHostID,
502+
"created_at": start, "end_date": end,
503+
},
504+
},
505+
"users": []map[string]any{
506+
{"user_id": cleanHostID, "handle": "clean_host"},
507+
{"user_id": lowScoreHostID, "handle": "low_score_host"},
508+
{"user_id": karmaMutedHostID, "handle": "karma_muted_host"},
509+
{"user_id": highKarmaMuterID, "handle": "high_karma_muter"},
510+
},
511+
"tracks": []map[string]any{
512+
{"track_id": cleanTrackID, "owner_id": cleanHostID, "created_at": start},
513+
{"track_id": lowScoreTrackID, "owner_id": lowScoreHostID, "created_at": start},
514+
{"track_id": karmaMutedTrackID, "owner_id": karmaMutedHostID, "created_at": start},
515+
},
516+
"muted_users": []map[string]any{
517+
// High-karma muter mutes the karma-muted host — combined with the
518+
// follower_count bump below, this should cross the threshold.
519+
{"user_id": highKarmaMuterID, "muted_user_id": karmaMutedHostID},
520+
},
521+
}
522+
database.Seed(app.pool.Replicas[0], fixtures)
523+
524+
// `aggregate_user` rows are created by the users trigger; tweak the two
525+
// fields we care about: score on the low-score host, and the muter's
526+
// follower_count so the karma-muted CTE actually trips.
527+
_, err := app.pool.Exec(context.Background(),
528+
`UPDATE aggregate_user SET score = $1 WHERE user_id = $2`,
529+
-1, lowScoreHostID,
530+
)
531+
if err != nil {
532+
t.Fatal(err)
533+
}
534+
_, err = app.pool.Exec(context.Background(),
535+
`UPDATE aggregate_user SET follower_count = $1 WHERE user_id = $2`,
536+
karmaCommentCountThreshold+1, highKarmaMuterID,
537+
)
538+
if err != nil {
539+
t.Fatal(err)
540+
}
541+
542+
cleanEventHash := trashid.MustEncodeHashID(801)
543+
544+
t.Run("only the clean host's contest is returned", func(t *testing.T) {
545+
status, body := testGet(t, app, "/v1/events/remix-contests")
546+
assert.Equal(t, 200, status)
547+
jsonAssert(t, body, map[string]any{
548+
"data.#": 1,
549+
"data.0.event_id": cleanEventHash,
550+
})
551+
})
552+
553+
t.Run("host with score < 0 is excluded", func(t *testing.T) {
554+
_, body := testGet(t, app, "/v1/events/remix-contests")
555+
eventIds := pluckStrings(body, "data.#.event_id")
556+
assert.NotContains(t, eventIds, trashid.MustEncodeHashID(802),
557+
"contest hosted by a user with aggregate_user.score < 0 must not be returned")
558+
})
559+
560+
t.Run("karma-muted host is excluded", func(t *testing.T) {
561+
_, body := testGet(t, app, "/v1/events/remix-contests")
562+
eventIds := pluckStrings(body, "data.#.event_id")
563+
assert.NotContains(t, eventIds, trashid.MustEncodeHashID(803),
564+
"contest hosted by a user in muted_by_karma must not be returned")
565+
})
566+
}

0 commit comments

Comments
 (0)