diff --git a/api/server.go b/api/server.go index 2c9fb07b..9531dd02 100644 --- a/api/server.go +++ b/api/server.go @@ -386,6 +386,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/handle/:handle/tracks", app.v1UserTracks) g.Get("/users/handle/:handle/tracks/count", app.v1UserTracksCount) g.Get("/users/handle/:handle/albums", app.v1UserAlbums) + g.Get("/users/handle/:handle/contests", app.v1UserContests) g.Get("/users/handle/:handle/playlists", app.v1UserPlaylists) g.Get("/users/handle/:handle/tracks/ai_attributed", app.v1UserTracksAiAttributed) g.Get("/users/handle/:handle/tracks/ai-attributed", app.v1UserTracksAiAttributed) @@ -424,6 +425,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/tracks/download_count", app.v1UserTracksDownloadCount) g.Get("/users/:userId/tracks/remixed", app.v1UserTracksRemixed) g.Get("/users/:userId/albums", app.v1UserAlbums) + g.Get("/users/:userId/contests", app.v1UserContests) g.Get("/users/:userId/playlists", app.v1UserPlaylists) g.Get("/users/:userId/feed", app.v1UsersFeed) g.Get("/users/:userId/connected_wallets", app.v1UsersConnectedWallets) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 6a72c5f0..75f085d7 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -5535,6 +5535,64 @@ paths: "500": description: Server error content: {} + /users/{id}/contests: + get: + tags: + - users + summary: Get contests hosted by user + description: + Get the remix contests hosted by a single user, ordered with + currently-active contests first (by soonest-ending end_date) + followed by ended contests (most-recently-ended first). Mirrors + the response shape of `GET /events/remix-contests` (data + + related users / tracks / entry_counts). + operationId: Get Contests By User + security: + - {} + - OAuth2: + - read + parameters: + - name: id + in: path + description: A User ID + required: true + schema: + type: string + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + - name: status + in: query + description: Filter contests by status + schema: + type: string + default: all + enum: + - active + - ended + - all + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/remix_contests_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /users/{id}/connected_wallets: get: tags: diff --git a/api/v1_users_contests.go b/api/v1_users_contests.go new file mode 100644 index 00000000..ac642642 --- /dev/null +++ b/api/v1_users_contests.go @@ -0,0 +1,204 @@ +package api + +import ( + "strings" + + "api.audius.co/api/dbv1" + "api.audius.co/trashid" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetUserContestsParams struct { + Limit int `query:"limit" default:"25" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0"` + Status string `query:"status" default:"all" validate:"oneof=active ended all"` +} + +// v1UserContests returns the remix contests hosted by a single user, in the +// same shape as GET /events/remix-contests (data + related users / tracks / +// entry_counts). Active contests come first (soonest-ending end_date), +// followed by ended contests (most-recently-ended first). +func (app *ApiServer) v1UserContests(c *fiber.Ctx) error { + params := GetUserContestsParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + userID := app.getUserId(c) + + filters := []string{ + "e.event_type = 'remix_contest'", + "e.is_deleted = false", + "e.user_id = @user_id", + "(e.entity_type != 'track' OR (t.track_id IS NOT NULL AND t.is_delete = false))", + } + + switch params.Status { + case "active": + filters = append(filters, "(e.end_date IS NULL OR e.end_date > NOW())") + case "ended": + filters = append(filters, "(e.end_date IS NOT NULL AND e.end_date <= NOW())") + } + + sql := ` + SELECT + e.event_id, + e.entity_type::event_entity_type AS entity_type, + e.user_id, + e.entity_id, + e.event_type::event_type AS event_type, + e.end_date, + e.is_deleted, + e.created_at, + e.updated_at, + e.event_data + FROM events e + 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 + WHERE ` + strings.Join(filters, " AND ") + ` + ORDER BY + 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, + e.event_id ASC + LIMIT @limit OFFSET @offset; + ` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "user_id": userID, + "limit": params.Limit, + "offset": params.Offset, + }) + if err != nil { + return err + } + defer rows.Close() + + var items []dbv1.GetEventsRow + for rows.Next() { + var row dbv1.GetEventsRow + if err := rows.Scan( + &row.EventID, + &row.EntityType, + &row.UserID, + &row.EntityID, + &row.EventType, + &row.EndDate, + &row.IsDeleted, + &row.CreatedAt, + &row.UpdatedAt, + &row.EventData, + ); err != nil { + return err + } + items = append(items, row) + } + if err := rows.Err(); err != nil { + return err + } + + data := make([]dbv1.FullEvent, 0, len(items)) + trackIDs := make([]int32, 0, len(items)) + userIDSet := map[int32]struct{}{} + for _, event := range items { + data = append(data, app.queries.ToFullEvent(event)) + if event.EntityType == dbv1.EventEntityTypeTrack && event.EntityID.Valid { + trackIDs = append(trackIDs, event.EntityID.Int32) + } + userIDSet[event.UserID] = struct{}{} + } + + myID := app.getMyId(c) + authedWallet := app.tryGetAuthedWallet(c) + + var trackMap map[int32]dbv1.Track + if len(trackIDs) > 0 { + trackMap, err = app.queries.TracksKeyed(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIDs, + MyID: myID, + AuthedWallet: authedWallet, + }, + }) + if err != nil { + return err + } + } + for _, t := range trackMap { + userIDSet[t.GetTracksRow.UserID] = struct{}{} + } + + userIDs := make([]int32, 0, len(userIDSet)) + for id := range userIDSet { + userIDs = append(userIDs, id) + } + var userMap map[int32]dbv1.User + if len(userIDs) > 0 { + userMap, err = app.queries.UsersKeyed(c.Context(), dbv1.GetUsersParams{ + Ids: userIDs, + MyID: myID, + }) + if err != nil { + return err + } + } + + users := make([]dbv1.User, 0, len(userMap)) + for _, u := range userMap { + users = append(users, u) + } + tracks := make([]dbv1.Track, 0, len(trackMap)) + for _, t := range trackMap { + tracks = append(tracks, t) + } + + 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, + }, + }) +} diff --git a/api/v1_users_contests_test.go b/api/v1_users_contests_test.go new file mode 100644 index 00000000..b2e81267 --- /dev/null +++ b/api/v1_users_contests_test.go @@ -0,0 +1,194 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +// TestGetUserContests exercises the per-user contest endpoint: +// - filters to contests hosted by the path user (rejects another host's +// contests) +// - resolves both /users/handle/{handle}/contests and /users/{id}/contests +// - keeps the same `data` + `related` shape as the global discovery +// endpoint (so the web tab can use the same primer code path) +// - status filter (active|ended|all) and pagination +func TestGetUserContests(t *testing.T) { + app := emptyTestApp(t) + + hostID := 9001 + otherHostID := 9099 // contests by this user must NOT appear in /users/9001/contests + ownerID := 9002 + remixer := 9003 + + activeTrackID := 8001 + endedTrackID := 8002 + otherTrackID := 8003 + + activeStart := parseTime(t, "2024-01-02") + activeEnd := parseTime(t, "2099-01-01") + endedStart := parseTime(t, "2024-02-02") + endedEnd := parseTime(t, "2024-02-10") + + inWindowCreated := parseTime(t, "2024-01-03") + + fixtures := database.FixtureMap{ + "events": []map[string]any{ + { + "event_id": 501, + "event_type": "remix_contest", + "entity_type": "track", + "entity_id": activeTrackID, + "user_id": hostID, + "created_at": activeStart, + "end_date": activeEnd, + }, + { + "event_id": 502, + "event_type": "remix_contest", + "entity_type": "track", + "entity_id": endedTrackID, + "user_id": hostID, + "created_at": endedStart, + "end_date": endedEnd, + }, + { + "event_id": 503, + "event_type": "remix_contest", + "entity_type": "track", + "entity_id": otherTrackID, + "user_id": otherHostID, + "created_at": activeStart, + "end_date": activeEnd, + }, + }, + "users": []map[string]any{ + {"user_id": hostID, "handle": "host", "handle_lc": "host"}, + {"user_id": otherHostID, "handle": "otherhost", "handle_lc": "otherhost"}, + {"user_id": ownerID, "handle": "owner"}, + {"user_id": remixer, "handle": "remixer"}, + }, + "tracks": []map[string]any{ + { + "track_id": activeTrackID, + "owner_id": ownerID, + "title": "Active Parent", + "created_at": activeStart, + }, + { + "track_id": endedTrackID, + "owner_id": ownerID, + "title": "Ended Parent", + "created_at": endedStart, + }, + { + "track_id": otherTrackID, + "owner_id": ownerID, + "title": "Other Host Parent", + "created_at": activeStart, + }, + { + "track_id": 8101, + "owner_id": remixer, + "title": "Active Remix In Window", + "created_at": inWindowCreated, + }, + }, + "remixes": []map[string]any{ + {"parent_track_id": activeTrackID, "child_track_id": 8101}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + hostHash := trashid.MustEncodeHashID(hostID) + activeTrackHash := trashid.MustEncodeHashID(activeTrackID) + endedTrackHash := trashid.MustEncodeHashID(endedTrackID) + activeEventHash := trashid.MustEncodeHashID(501) + endedEventHash := trashid.MustEncodeHashID(502) + otherEventHash := trashid.MustEncodeHashID(503) + + t.Run("by id: ordered active before ended, filtered to host", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/"+hostHash+"/contests") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.event_id": activeEventHash, + "data.0.entity_id": activeTrackHash, + "data.1.event_id": endedEventHash, + "data.1.entity_id": endedTrackHash, + }) + // other host's contest must not leak in + eventIds := pluckStrings(body, "data.#.event_id") + assert.NotContains(t, eventIds, otherEventHash) + }) + + t.Run("by handle: same result", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/handle/host/contests") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.event_id": activeEventHash, + "data.1.event_id": endedEventHash, + }) + }) + + t.Run("status=active filters out ended", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/"+hostHash+"/contests?status=active") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": activeEventHash, + }) + }) + + t.Run("status=ended filters out active", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/"+hostHash+"/contests?status=ended") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": endedEventHash, + }) + }) + + t.Run("pagination: limit=1 returns first page only", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/"+hostHash+"/contests?limit=1") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": activeEventHash, + }) + }) + + t.Run("pagination: offset=1 skips first page", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/"+hostHash+"/contests?limit=1&offset=1") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": endedEventHash, + }) + }) + + t.Run("related contains host + entry counts", func(t *testing.T) { + status, body := testGet(t, app, "/v1/users/"+hostHash+"/contests") + assert.Equal(t, 200, status) + + userIds := pluckStrings(body, "related.users.#.id") + assert.Contains(t, userIds, hostHash) + + jsonAssert(t, body, map[string]any{ + "related.entry_counts." + activeTrackHash: float64(1), + "related.entry_counts." + endedTrackHash: float64(0), + }) + }) + + t.Run("user with no contests returns empty data", func(t *testing.T) { + ownerHash := trashid.MustEncodeHashID(ownerID) + status, body := testGet(t, app, "/v1/users/"+ownerHash+"/contests") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 0, + }) + }) +}