From ef54c9f1dde7d98ee60856523c7a7daf8783ff1d Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 5 Jun 2026 20:00:58 -0700 Subject: [PATCH] feat(events): return related.entry_counts on /events/entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /v1/events/entity endpoint returned only `data`, so every consumer that resolves a contest through it had to fire a separate `/tracks/{id}/remixes?only_contest_entries=true&limit=0` just to render the entry-count badge. This is an N+1 on web Explore's featured contests (one count request per card) and an extra round-trip on the track-page contest section and cold/deep-linked contest pages. Compute per-contest entry counts alongside the events query and return them under `related.entry_counts`, keyed by the contest's parent track hashid — mirroring the existing /events/remix-contests discovery endpoint so the client can prime `useRemixesCount({ isContestEntry: true })` directly. The count uses the same in-window filter (child track created after contest start, before end_date, currently listed) and only applies to remix_contest events on track entities. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/swagger/swagger-v1.yaml | 2 + api/v1_events.go | 60 ++++++++++++++++++++++++++ api/v1_events_test.go | 84 +++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index e4f28066..57859456 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -11981,6 +11981,8 @@ components: type: array items: $ref: "#/components/schemas/event" + related: + $ref: "#/components/schemas/remix_contests_related" remix_contests_related: type: object properties: diff --git a/api/v1_events.go b/api/v1_events.go index a38a75bb..effa5156 100644 --- a/api/v1_events.go +++ b/api/v1_events.go @@ -4,6 +4,7 @@ import ( "api.audius.co/api/dbv1" "api.audius.co/trashid" "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" ) type GetEventsParams struct { @@ -54,7 +55,66 @@ func (app *ApiServer) v1Events(c *fiber.Ctx) error { data = append(data, app.queries.ToFullEvent(event)) } + // Compute per-contest entry counts so consumers that resolve a contest via + // this endpoint (the track-page contest section, cold/deep-linked contest + // pages, web Explore's featured contests) can prime + // useRemixesCount({ isContestEntry: true }) instead of firing a separate + // /tracks/{id}/remixes?only_contest_entries=true&limit=0 per card. Mirrors + // the entry-count filter in v1EventsRemixContests: a child track is an entry + // iff it was created after the contest started, before its end_date, and is + // currently listed. Only remix_contest events on track entities have a + // meaningful entry count. + entryCounts := map[string]int64{} + contestEventIds := []int32{} + for _, event := range recentEvents { + if event.EventType == dbv1.EventTypeRemixContest && + event.EntityType == dbv1.EventEntityTypeTrack && + event.EntityID.Valid { + contestEventIds = append(contestEventIds, event.EventID) + // Default to 0 so the UI primes a definitive "no entries" and + // still skips the count-only request for empty contests. + entryCounts[trashid.MustEncodeHashID(int(event.EntityID.Int32))] = 0 + } + } + + if len(contestEventIds) > 0 { + countSql := ` + SELECT e.entity_id, COUNT(DISTINCT ct.track_id) AS entry_count + FROM events e + JOIN remixes rm ON rm.parent_track_id = e.entity_id + JOIN tracks ct ON ct.track_id = rm.child_track_id + WHERE e.event_id = ANY(@event_ids) + 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) + GROUP BY e.entity_id; + ` + rows, err := app.pool.Query(c.Context(), countSql, pgx.NamedArgs{ + "event_ids": contestEventIds, + }) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var entityID int32 + var entryCount int64 + if err := rows.Scan(&entityID, &entryCount); err != nil { + return err + } + entryCounts[trashid.MustEncodeHashID(int(entityID))] = entryCount + } + if err := rows.Err(); err != nil { + return err + } + } + return c.JSON(fiber.Map{ "data": data, + "related": fiber.Map{ + "entry_counts": entryCounts, + }, }) } diff --git a/api/v1_events_test.go b/api/v1_events_test.go index c0434358..30d0bdbd 100644 --- a/api/v1_events_test.go +++ b/api/v1_events_test.go @@ -5,6 +5,7 @@ import ( "testing" "api.audius.co/api/dbv1" + "api.audius.co/database" "api.audius.co/trashid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -87,6 +88,89 @@ func TestGetEventsExcludesDeletedTracks(t *testing.T) { }) } +// TestGetEntityEventsEntryCounts verifies the /events/entity endpoint returns +// related.entry_counts using the same in-window filter as the remix-contests +// discovery endpoint, so callers can prime useRemixesCount({ isContestEntry: +// true }) instead of issuing a separate /tracks/{id}/remixes?limit=0 per card. +func TestGetEntityEventsEntryCounts(t *testing.T) { + app := emptyTestApp(t) + + hostID := 7101 + remixer := 7102 + + contestTrackID := 7001 + contestStart := parseTime(t, "2024-01-02") + contestEnd := parseTime(t, "2099-01-01") + + inWindow := parseTime(t, "2024-01-03") + tooEarly := parseTime(t, "2024-01-01") // before contest start => excluded + + fixtures := database.FixtureMap{ + "events": []map[string]any{ + { + "event_id": 601, + "event_type": "remix_contest", + "entity_type": "track", + "entity_id": contestTrackID, + "user_id": hostID, + "created_at": contestStart, + "end_date": contestEnd, + }, + }, + "users": []map[string]any{ + {"user_id": hostID, "handle": "entryhost"}, + {"user_id": remixer, "handle": "entryremixer"}, + }, + "tracks": []map[string]any{ + { + "track_id": contestTrackID, + "owner_id": hostID, + "title": "Contest Parent", + "created_at": contestStart, + }, + { + "track_id": 7201, + "owner_id": remixer, + "title": "In Window Entry A", + "created_at": inWindow, + }, + { + "track_id": 7202, + "owner_id": remixer, + "title": "In Window Entry B", + "created_at": inWindow, + }, + { + "track_id": 7203, + "owner_id": remixer, + "title": "Too Early (excluded)", + "created_at": tooEarly, + }, + }, + "remixes": []map[string]any{ + {"parent_track_id": contestTrackID, "child_track_id": 7201}, + {"parent_track_id": contestTrackID, "child_track_id": 7202}, + {"parent_track_id": contestTrackID, "child_track_id": 7203}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + contestTrackHash := trashid.MustEncodeHashID(contestTrackID) + + status, body := testGet( + t, app, + "/v1/events/entity?entity_id="+contestTrackHash, + ) + assert.Equal(t, 200, status) + + // 2 in-window remixes counted; the pre-window remix (7203) excluded. + jsonAssert(t, body, map[string]any{ + "data.0.event_id": trashid.MustEncodeHashID(601), + "data.0.entity_id": contestTrackHash, + "related.entry_counts." + contestTrackHash: float64(2), + }) +} + func TestGetEventsExcludesAccessAuthoritiesTracks(t *testing.T) { app := testAppWithFixtures(t) ctx := context.Background()