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
68 changes: 65 additions & 3 deletions api/v1_event_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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)
Expand Down
103 changes: 96 additions & 7 deletions api/v1_event_comments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}

Expand Down Expand Up @@ -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)

Expand Down
101 changes: 47 additions & 54 deletions api/v1_events_remix_contests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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, &params); err != nil {
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -65,17 +92,20 @@ 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
}
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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
},
})
}
Loading
Loading