diff --git a/api/server.go b/api/server.go index 0cbd41e9..5adb4c2d 100644 --- a/api/server.go +++ b/api/server.go @@ -512,6 +512,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/fan-club/feed", app.v1FanClubFeed) g.Get("/events/:eventId/comments", app.v1EventComments) + g.Get("/events/:eventId/followers", app.v1EventsFollowers) g.Get("/events/:eventId/follow_state", app.v1EventFollowState) g.Get("/events/:eventId/follow-state", app.v1EventFollowState) g.Post("/events/:eventId/follow", app.requireAuthMiddleware, app.requireWriteScope, app.postV1EventFollow) diff --git a/api/v1_events_followers.go b/api/v1_events_followers.go index 0605cfa1..22cfdebb 100644 --- a/api/v1_events_followers.go +++ b/api/v1_events_followers.go @@ -110,6 +110,40 @@ func (app *ApiServer) deleteV1EventFollow(c *fiber.Ctx) error { }) } +// v1EventsFollowers returns the list of users subscribed to a given +// remix-contest event, ordered by each user's own follower count so the +// most-followed fans surface first in the avatar stack / leaderboard. The +// response shape matches /v1/users/:userId/followers so the same User +// renderers can consume it. +func (app *ApiServer) v1EventsFollowers(c *fiber.Ctx) error { + eventID, err := trashid.DecodeHashId(c.Params("eventId")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid event id") + } + + // `subscriptions.user_id` mirrors the event id (legacy column), so the + // `USING (user_id)` shortcut from v1UsersFollowers would be ambiguous + // here — qualify both joins explicitly against the user being looked + // up (subscriber_id). + sql := ` + SELECT subscriptions.subscriber_id + FROM subscriptions + JOIN users ON users.user_id = subscriptions.subscriber_id + JOIN aggregate_user ON aggregate_user.user_id = subscriptions.subscriber_id + WHERE subscriptions.entity_type = 'Event' + AND subscriptions.entity_id = @eventId + AND subscriptions.is_current = true + AND subscriptions.is_delete = false + AND users.is_deactivated = false + ORDER BY aggregate_user.follower_count DESC + LIMIT @limit + OFFSET @offset + ` + return app.queryUsers(c, sql, pgx.NamedArgs{ + "eventId": eventID, + }) +} + // v1EventFollowState returns { is_followed, follower_count } for an event. // Used by the contest page to render the follow button in the right state. func (app *ApiServer) v1EventFollowState(c *fiber.Ctx) error { diff --git a/api/v1_events_followers_test.go b/api/v1_events_followers_test.go index 313637cc..80931071 100644 --- a/api/v1_events_followers_test.go +++ b/api/v1_events_followers_test.go @@ -491,3 +491,112 @@ func TestPostEventFollow_DeletedEventIs404(t *testing.T) { ) assert.Equal(t, 404, status) } + +func TestEventsFollowers_ReturnsOnlyLiveEventSubscribers(t *testing.T) { + // /v1/events/:eventId/followers returns the list of users subscribed + // to the event. Mirrors the shape + filtering rules of follow_state: + // entity_type='Event', is_current & !is_delete, and ignores legacy + // user-type subscriptions with a colliding numeric id. + app := emptyTestApp(t) + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testEventFollowersBaseUsers(), + "tracks": { + { + "track_id": 1, + "owner_id": 1, + "title": "Original", + "created_at": "2020-01-01 00:00:00", + }, + }, + "events": { + { + "event_id": 200, + "event_type": "remix_contest", + "user_id": 1, + "entity_type": "track", + "entity_id": 1, + "event_data": map[string]any{"description": "remix"}, + "created_at": "2020-05-01 00:00:00", + "updated_at": "2020-05-01 00:00:00", + }, + }, + "subscriptions": []map[string]any{ + { + "subscriber_id": 2, + "user_id": 200, + "entity_type": "Event", + "entity_id": 200, + "is_current": true, + "is_delete": false, + "blockhash": "bh1", + "blocknumber": 101, + "txhash": "tx1", + }, + { + "subscriber_id": 3, + "user_id": 200, + "entity_type": "Event", + "entity_id": 200, + "is_current": true, + "is_delete": false, + "blockhash": "bh2", + "blocknumber": 101, + "txhash": "tx2", + }, + // Legacy user-type subscription with a colliding numeric id — + // must NOT show up. + { + "subscriber_id": 1, + "user_id": 200, + "entity_type": "User", + "entity_id": nil, + "is_current": true, + "is_delete": false, + "blockhash": "bh3", + "blocknumber": 101, + "txhash": "tx3", + }, + // Deleted event subscription — must NOT show up. + { + "subscriber_id": 1, + "user_id": 200, + "entity_type": "Event", + "entity_id": 200, + "is_current": true, + "is_delete": true, + "blockhash": "bh4", + "blocknumber": 101, + "txhash": "tx4", + }, + }, + }) + + encEvent, err := trashid.EncodeHashId(200) + require.NoError(t, err) + status, body := testGet(t, app, "/v1/events/"+encEvent+"/followers") + require.Equal(t, 200, status, string(body)) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + }) +} + +func TestEventsFollowers_UnknownEventReturnsEmptyList(t *testing.T) { + app := emptyTestApp(t) + + encEvent, err := trashid.EncodeHashId(9999) + require.NoError(t, err) + status, body := testGet(t, app, "/v1/events/"+encEvent+"/followers") + require.Equal(t, 200, status, string(body)) + jsonAssert(t, body, map[string]any{ + "data.#": 0, + }) +} + +func TestEventsFollowers_InvalidEventIdReturns400(t *testing.T) { + app := emptyTestApp(t) + + status, _ := testGet(t, app, "/v1/events/not-a-hashid/followers") + assert.Equal(t, 400, status) +}