From ef54c9f1dde7d98ee60856523c7a7daf8783ff1d Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 5 Jun 2026 20:00:58 -0700 Subject: [PATCH 1/2] 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() From 38947b0f47d91b45367b25359f17e13ed265b591 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 9 Jun 2026 14:39:22 -0700 Subject: [PATCH 2/2] feat: add permalink to event API responses Left-join event_routes on event queries to expose permalink field. Adds migration 0218_create_event_routes.sql, updates GetEvents query and v1_events_remix_contests handler, seeds fixture data in tests. Co-Authored-By: Claude Sonnet 4.6 --- api/auth_middleware.go | 90 +++++++++++++- api/auth_middleware_test.go | 55 +++++++++ api/dbv1/get_events.sql.go | 11 +- api/dbv1/queries/get_events.sql | 9 +- api/server_test.go | 1 + api/testdata/event_fixtures.go | 10 ++ api/v1_events_remix_contests.go | 7 ++ api/v1_events_test.go | 25 ++-- api/v1_playlists_trending_test.go | 17 +++ database/seed.go | 9 ++ ddl/migrations/0218_create_event_routes.sql | 22 ++++ scripts/fix_underground_20260605.sql | 129 ++++++++++++++++++++ sql/01_schema.sql | 15 +++ 13 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 ddl/migrations/0218_create_event_routes.sql create mode 100644 scripts/fix_underground_20260605.sql diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 2060f207..e5015098 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -349,12 +349,28 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // Not authorized to act on behalf of myId. // - // Exception: /users/:userId/feed/for-you accepts user_id as a viewer hint - // used only for response decoration (has_current_user_reposted etc.); the - // path :userId — not user_id — controls what gets personalized. Treat the - // query user_id as advisory rather than authoritative on this route so - // the endpoint can be called like the other public read endpoints. - allowUnauthenticatedViewerId := strings.HasSuffix(c.Path(), "/feed/for-you") + // Exceptions: public discovery reads where user_id is purely a + // viewer hint used for response decoration + // (has_current_user_reposted, has_current_user_saved, etc.) with no + // permission semantics tied to it. Treat the query user_id as + // advisory rather than authoritative on these routes so logged-in + // SDK clients can pass it without forging signature headers. + // + // Anything in this list MUST satisfy two conditions: + // 1. Method is GET (no writes — writes must remain authoritative). + // 2. user_id is used only to populate has_current_user_* / similar + // decoration flags. It MUST NOT control content selection, + // permission, or row visibility — that responsibility lives on + // a path :userId param or an explicit auth middleware. + // + // Follow-up: the list below is the tactical patch for the most + // affected discovery surfaces. The longer-term fix is a per-route + // opt-in marker (e.g. an `advisoryUserId` middleware attached at + // route registration) so this allowlist doesn't have to grow with + // every new public read. Tracked in api#TBD. + path := c.Path() + allowUnauthenticatedViewerId := c.Method() == fiber.MethodGet && + isAdvisoryUserIdPath(path) if myId != 0 && !pkceAuthed && !allowUnauthenticatedViewerId && !app.isAuthorizedRequest(c.Context(), myId, wallet) { return fiber.NewError( @@ -382,6 +398,68 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { return c.Next() } +// isAdvisoryUserIdPath matches request paths where ?user_id is treated as a +// viewer hint for response decoration only — no permission semantics. See the +// big comment in authMiddleware for the contract; this matcher must stay in +// sync with it. +// +// All entries are conceptual route patterns rewritten as a literal path-match +// or a small predicate. Anything load-bearing on user_id (e.g. /me, /account, +// any write) is intentionally absent. +func isAdvisoryUserIdPath(path string) bool { + // Normalize: drop the /v1 prefix so the matcher is version-agnostic. + stripped := strings.TrimPrefix(path, "/v1") + + switch stripped { + case "/playlists", + "/playlists/trending", + "/playlists/top", + "/playlists/new-releases", + "/playlists/by_permalink", + "/playlists/search", + "/tracks", + "/tracks/trending", + "/tracks/recommended", + "/tracks/trending/ids", + "/users", + "/users/search", + "/users/top", + "/users/genre/top": + return true + } + + // /users/:userId/feed/for-you and other /feed/for-you variants. + if strings.HasSuffix(stripped, "/feed/for-you") { + return true + } + + // Dynamic single-resource reads: /playlists/, /tracks/, + // /users/, /users/handle/. These are decorative — the path + // param controls the resource; user_id only personalizes flags. + segs := strings.Split(strings.TrimPrefix(stripped, "/"), "/") + if len(segs) >= 2 { + switch segs[0] { + case "playlists", "tracks": + // /playlists/, /tracks/ — but NOT /playlists//stream + // etc. (those endpoints may have their own semantics). Match + // only the two-segment case here; sub-resources stay strict. + if len(segs) == 2 { + return true + } + case "users": + // /users/, /users/handle/ — single user fetch. + // Their sub-resource reads (/users//tracks, /followers, + // etc.) are NOT in this tactical exemption; if needed, add + // them explicitly. + if len(segs) == 2 || (len(segs) == 3 && segs[1] == "handle") { + return true + } + } + } + + return false +} + // Middleware to require auth for the userId in the route params // Returns a 403 if the authedWallet is not authorized to act on behalf of the userId // Should be placed after authMiddleware diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 6ad08207..6822df8b 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -435,3 +435,58 @@ func base64Encode(s string) string { func base64EncodeBytes(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + +// TestIsAdvisoryUserIdPath documents the contract for which public read +// surfaces treat ?user_id as advisory (decoration only) vs authoritative +// (permission/selection). The list MUST stay narrow — anything that +// materially uses user_id beyond decoration should NOT be marked advisory. +func TestIsAdvisoryUserIdPath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + // Documented exempt — viewer hint only. + {"/v1/playlists/trending", true}, + {"/v1/playlists/top", true}, + {"/v1/playlists/new-releases", true}, + {"/v1/playlists/by_permalink", true}, + {"/v1/playlists/search", true}, + {"/v1/tracks/trending", true}, + {"/v1/tracks/recommended", true}, + + // Single-resource reads — :id is the resource selector; user_id + // only personalizes has_current_user_*. + {"/v1/playlists/abc123", true}, + {"/v1/tracks/def456", true}, + {"/v1/users/oaM5J", true}, + {"/v1/users/handle/somebody", true}, + + // For You — the canonical example. + {"/v1/users/oaM5J/feed/for-you", true}, + + // NOT exempt — authoritative on myId. /me derives "who is the + // caller" from user_id, so impersonation here would be a + // security hole. (See big comment in authMiddleware.) + {"/v1/me", false}, + {"/v1/oauth/me", false}, + {"/v1/users/account/0xabc", false}, + + // NOT exempt — sub-resource reads that this tactical patch + // doesn't claim coverage for. They may need to be added in a + // follow-up; for now they stay strict (which is the existing + // pre-patch behavior). + {"/v1/playlists/abc/tracks", false}, + {"/v1/users/oaM5J/tracks", false}, + {"/v1/users/oaM5J/followers", false}, + + // Random paths should never match. + {"/v1/notifications", false}, + {"/v1/", false}, + {"/", false}, + } + + for _, tc := range cases { + got := isAdvisoryUserIdPath(tc.path) + assert.Equalf(t, tc.want, got, "isAdvisoryUserIdPath(%q)", tc.path) + } +} diff --git a/api/dbv1/get_events.sql.go b/api/dbv1/get_events.sql.go index 8f33c7ea..93b0b8cc 100644 --- a/api/dbv1/get_events.sql.go +++ b/api/dbv1/get_events.sql.go @@ -23,10 +23,17 @@ SELECT e.is_deleted AS is_deleted, e.created_at AS created_at, e.updated_at AS updated_at, - e.event_data AS event_data + e.event_data AS event_data, + CASE + WHEN er.slug IS NOT NULL AND u.handle_lc IS NOT NULL + THEN '/' || u.handle_lc || '/contest/' || er.slug + ELSE NULL + END AS permalink 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 +LEFT JOIN event_routes er ON er.event_id = e.event_id AND er.is_current = true +LEFT JOIN users u ON u.user_id = e.user_id AND u.is_current = true WHERE ($1::int[] = '{}' OR e.entity_id = ANY($1::int[])) AND ($2::int[] = '{}' OR e.event_id = ANY($2::int[])) @@ -60,6 +67,7 @@ type GetEventsRow struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` EventData *EventData `json:"event_data"` + Permalink *string `json:"permalink"` } func (q *Queries) GetEvents(ctx context.Context, arg GetEventsParams) ([]GetEventsRow, error) { @@ -90,6 +98,7 @@ func (q *Queries) GetEvents(ctx context.Context, arg GetEventsParams) ([]GetEven &i.CreatedAt, &i.UpdatedAt, &i.EventData, + &i.Permalink, ); err != nil { return nil, err } diff --git a/api/dbv1/queries/get_events.sql b/api/dbv1/queries/get_events.sql index 557ffa0f..3083851b 100644 --- a/api/dbv1/queries/get_events.sql +++ b/api/dbv1/queries/get_events.sql @@ -9,10 +9,17 @@ SELECT e.is_deleted AS is_deleted, e.created_at AS created_at, e.updated_at AS updated_at, - e.event_data AS event_data + e.event_data AS event_data, + CASE + WHEN er.slug IS NOT NULL AND u.handle_lc IS NOT NULL + THEN '/' || u.handle_lc || '/contest/' || er.slug + ELSE NULL + END AS permalink 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 +LEFT JOIN event_routes er ON er.event_id = e.event_id AND er.is_current = true +LEFT JOIN users u ON u.user_id = e.user_id AND u.is_current = true WHERE (@entity_ids::int[] = '{}' OR e.entity_id = ANY(@entity_ids::int[])) AND (@event_ids::int[] = '{}' OR e.event_id = ANY(@event_ids::int[])) diff --git a/api/server_test.go b/api/server_test.go index 7075a8a2..2e45f2d9 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -94,6 +94,7 @@ func testAppWithFixtures(t *testing.T) *ApiServer { database.SeedTable(app.pool.Replicas[0], "associated_wallets", testdata.ConnectedWallets) database.SeedTable(app.pool.Replicas[0], "developer_apps", testdata.DeveloperApps) database.SeedTable(app.pool.Replicas[0], "events", testdata.Events) + database.SeedTable(app.pool.Replicas[0], "event_routes", testdata.EventRoutes) database.SeedTable(app.pool.Replicas[0], "follows", testdata.Follows) database.SeedTable(app.pool.Replicas[0], "grants", testdata.Grants) database.SeedTable(app.pool.Replicas[0], "playlists", testdata.Playlists) diff --git a/api/testdata/event_fixtures.go b/api/testdata/event_fixtures.go index cf34ed26..3cfd8db5 100644 --- a/api/testdata/event_fixtures.go +++ b/api/testdata/event_fixtures.go @@ -50,3 +50,13 @@ var Events = []map[string]any{ "is_deleted": false, }, } + +// EventRoutes seeds event_routes rows that match the Events fixtures above. +// slug is keyed by event_id so tests can assert permalink construction. +var EventRoutes = []map[string]any{ + {"event_id": 1, "owner_id": 200, "slug": "summer-remix-contest"}, + {"event_id": 2, "owner_id": 200, "slug": "live-at-the-venue"}, + {"event_id": 4, "owner_id": 200, "slug": "fall-remix-contest"}, + {"event_id": 5, "owner_id": 200, "slug": "live-fall-show"}, + {"event_id": 6, "owner_id": 201, "slug": "indie-remix-contest"}, +} diff --git a/api/v1_events_remix_contests.go b/api/v1_events_remix_contests.go index e7790c32..508fa081 100644 --- a/api/v1_events_remix_contests.go +++ b/api/v1_events_remix_contests.go @@ -103,10 +103,16 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { e.created_at, e.updated_at, e.event_data, + CASE + WHEN er.slug IS NOT NULL AND u.handle_lc IS NOT NULL + THEN '/' || u.handle_lc || '/contest/' || er.slug + ELSE NULL + END AS permalink, 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 event_routes er ON er.event_id = e.event_id AND er.is_current = true LEFT JOIN tracks t ON t.track_id = e.entity_id AND t.is_current = true AND e.entity_type = 'track' @@ -174,6 +180,7 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { &row.CreatedAt, &row.UpdatedAt, &row.EventData, + &row.Permalink, &entryCount, ); err != nil { return err diff --git a/api/v1_events_test.go b/api/v1_events_test.go index 30d0bdbd..15da1a8a 100644 --- a/api/v1_events_test.go +++ b/api/v1_events_test.go @@ -21,20 +21,25 @@ func TestGetEvents(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.0.event_id": trashid.MustEncodeHashID(1), - "data.0.entity_id": trashid.MustEncodeHashID(100), + "data.0.event_id": trashid.MustEncodeHashID(1), + "data.0.entity_id": trashid.MustEncodeHashID(100), + "data.0.permalink": "/eventsuser/contest/summer-remix-contest", - "data.1.event_id": trashid.MustEncodeHashID(2), - "data.1.entity_id": trashid.MustEncodeHashID(100), + "data.1.event_id": trashid.MustEncodeHashID(2), + "data.1.entity_id": trashid.MustEncodeHashID(100), + "data.1.permalink": "/eventsuser/contest/live-at-the-venue", - "data.2.event_id": trashid.MustEncodeHashID(4), - "data.2.entity_id": trashid.MustEncodeHashID(101), + "data.2.event_id": trashid.MustEncodeHashID(4), + "data.2.entity_id": trashid.MustEncodeHashID(101), + "data.2.permalink": "/eventsuser/contest/fall-remix-contest", - "data.3.event_id": trashid.MustEncodeHashID(5), - "data.3.entity_id": trashid.MustEncodeHashID(101), + "data.3.event_id": trashid.MustEncodeHashID(5), + "data.3.entity_id": trashid.MustEncodeHashID(101), + "data.3.permalink": "/eventsuser/contest/live-fall-show", - "data.4.event_id": trashid.MustEncodeHashID(6), - "data.4.entity_id": trashid.MustEncodeHashID(102), + "data.4.event_id": trashid.MustEncodeHashID(6), + "data.4.entity_id": trashid.MustEncodeHashID(102), + "data.4.permalink": "/eventsuser2/contest/indie-remix-contest", }) } diff --git a/api/v1_playlists_trending_test.go b/api/v1_playlists_trending_test.go index 8d78a426..3355ed06 100644 --- a/api/v1_playlists_trending_test.go +++ b/api/v1_playlists_trending_test.go @@ -289,6 +289,23 @@ func TestGetTrendingPlaylists_Albums(t *testing.T) { }) } + // Regression: a signed-out SDK caller passes user_id as a viewer hint + // (purely for has_current_user_* decoration). The middleware must + // treat it as advisory and not 403 the request — otherwise every + // logged-in client gets an empty trending list. Same path serves both + // type=playlist and type=album, so one exemption covers both. + { + viewer := trashid.MustEncodeHashID(1) + status, body := testGet(t, + app, + "/v1/playlists/trending?limit=5&type=album&user_id="+viewer, + nil) + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 5, + }) + } + // Cache safety net: if an album becomes private after the qualified-ids // cache was populated (a stale entry), the response handler should still // drop it before returning. Flip album 1 to private and re-call — the diff --git a/database/seed.go b/database/seed.go index 8563580d..bbdd63a8 100644 --- a/database/seed.go +++ b/database/seed.go @@ -215,6 +215,15 @@ var ( "updated_at": time.Now(), "txhash": "tx123", }, + "event_routes": { + "slug": nil, + "owner_id": nil, + "event_id": nil, + "is_current": true, + "blockhash": "block_abc123", + "blocknumber": 101, + "txhash": "tx123", + }, "track_routes": { "slug": nil, "title_slug": nil, diff --git a/ddl/migrations/0218_create_event_routes.sql b/ddl/migrations/0218_create_event_routes.sql new file mode 100644 index 00000000..3fbb9e3b --- /dev/null +++ b/ddl/migrations/0218_create_event_routes.sql @@ -0,0 +1,22 @@ +-- Add event_routes table to support permalink generation for events. +-- +-- Mirrors the shape of track_routes / playlist_routes: slug is set by the +-- indexer when an event is created, owner_id points to the event's host user, +-- and is_current flags the canonical row so a LEFT JOIN ON is_current = true +-- always lands on at most one row per event. + +CREATE TABLE IF NOT EXISTS public.event_routes ( + slug character varying NOT NULL, + owner_id integer NOT NULL, + event_id integer NOT NULL, + is_current boolean NOT NULL, + blockhash character varying NOT NULL, + blocknumber integer NOT NULL, + txhash character varying NOT NULL +); + +ALTER TABLE ONLY public.event_routes + ADD CONSTRAINT event_routes_pkey PRIMARY KEY (owner_id, slug); + +CREATE INDEX IF NOT EXISTS event_routes_event_id_idx + ON public.event_routes USING btree (event_id); diff --git a/scripts/fix_underground_20260605.sql b/scripts/fix_underground_20260605.sql new file mode 100644 index 00000000..0c54bfbf --- /dev/null +++ b/scripts/fix_underground_20260605.sql @@ -0,0 +1,129 @@ +-- One-off correction for the 2026-06-05 UNDERGROUND_TRACKS trending week. +-- +-- Context: before the fix in PR #915, the weekly underground winners were +-- computed by reading raw UNDERGROUND_TRACKS scores (which index_trending +-- populates identically to TRACKS, with no eligibility filter), so the +-- winners came out ~identical to regular trending — mainstream artists like +-- BONNIEXCLYDE/Gramatik instead of genuinely niche artists. PR #915 is now +-- deployed, so future weeks are correct; this script repairs 2026-06-05. +-- +-- What this does, in ONE transaction: +-- 1. Re-derives the CORRECT underground winners LIVE from track_trending_scores +-- using the exact merged-fix query (follower<1500, following<1500, excluding +-- the global top-20 TRACKS). Deriving live (vs. hardcoding ids) guarantees +-- the rows match the deployed algorithm even if scores shifted slightly. +-- 2. Replaces the 10 bad trending_results rows (the public "winners" list). +-- 3. Removes the UNDISBURSED wrong `tut` rewards and mints the correct ones. +-- Already-disbursed slots (ranks 6/8/9 — diskadebass/vansnydermusic/binap, +-- 100 AUDIO each) are LEFT AS-IS: on-chain payouts can't be clawed back, and +-- the ON CONFLICT DO NOTHING below skips those specifiers automatically, so +-- the correct artists for those 3 ranks are intentionally not credited. +-- +-- Ordering matters: trending_results is written BEFORE user_challenges so the +-- handle_trending AFTER-INSERT trigger (which recovers the track id from +-- trending_results by rank/type/week/user_id) emits the correct +-- `trending_underground` notification for each freshly-credited artist. +-- +-- Single transaction => no other indexer pod ever observes an empty week, so an +-- old/new pod's 30s tick can't race in bad rows. The week ends up with +-- trending_results present, which trips the processor's idempotency guard and +-- keeps the job from re-running this week. + +\set ON_ERROR_STOP on +\set week '2026-06-05' + +BEGIN; + +-- 1. Correct underground winners (mirrors jobs/challenges/trending.go Reconcile +-- underground branch). amount: ranks 1-5 => 1000, ranks 6-10 => 100. +CREATE TEMP TABLE correct_ug ON COMMIT DROP AS +SELECT rank, track_id, user_id, + CASE WHEN rank <= 5 THEN 1000 ELSE 100 END AS amount +FROM ( + WITH top_trending AS ( + SELECT s.track_id + FROM track_trending_scores s + JOIN tracks t ON t.track_id = s.track_id + AND t.is_current AND NOT t.is_delete AND NOT t.is_unlisted AND t.is_available + WHERE s.type = 'TRACKS' AND s.version = 'pnagD' AND s.time_range = 'week' + ORDER BY s.score DESC, s.track_id DESC + LIMIT 20 + ) + SELECT + row_number() OVER (ORDER BY s.score DESC, s.track_id DESC)::int AS rank, + s.track_id, + t.owner_id AS user_id + FROM track_trending_scores s + JOIN tracks t ON t.track_id = s.track_id + AND t.is_current AND NOT t.is_delete AND NOT t.is_unlisted AND t.is_available + JOIN aggregate_user au ON au.user_id = t.owner_id + WHERE s.type = 'TRACKS' AND s.version = 'pnagD' AND s.time_range = 'week' + AND au.follower_count < 1500 + AND au.following_count < 1500 + AND NOT EXISTS (SELECT 1 FROM top_trending tt WHERE tt.track_id = s.track_id) +) ranked +WHERE rank <= 10; + +-- Safety: bail unless we derived a full top-10. +DO $$ +BEGIN + IF (SELECT count(*) FROM correct_ug) <> 10 THEN + RAISE EXCEPTION 'expected 10 correct underground winners, got %', + (SELECT count(*) FROM correct_ug); + END IF; +END $$; + +-- 2. Public winners list: replace the 10 bad rows with the correct 10. +DELETE FROM trending_results +WHERE type = 'UNDERGROUND_TRACKS' AND version = 'pnagD' AND week = :'week'::date; + +INSERT INTO trending_results (user_id, id, rank, type, version, week) +SELECT user_id, track_id::text, rank, 'UNDERGROUND_TRACKS', 'pnagD', :'week'::date +FROM correct_ug; + +-- 3a. Neutralize the wrong, still-unclaimed rewards. The NOT EXISTS guard means +-- we only ever delete specifiers with no on-chain disbursement, so an +-- already-paid slot is never removed even if it was claimed since analysis. +DELETE FROM user_challenges uc +WHERE uc.challenge_id = 'tut' + AND uc.specifier LIKE :'week' || ':%' + AND NOT EXISTS ( + SELECT 1 FROM sol_reward_disbursements srd + WHERE srd.challenge_id = uc.challenge_id + AND srd.specifier = uc.specifier + ); + +-- 3b. Mint the correct rewards. ON CONFLICT DO NOTHING skips any specifier still +-- present (the disbursed ranks 6/8/9), leaving those paid rows untouched. +-- Each inserted row fires handle_trending -> trending_underground notif. +INSERT INTO user_challenges + (challenge_id, user_id, specifier, is_complete, current_step_count, amount, created_at, completed_at) +SELECT 'tut', user_id, :'week' || ':' || rank, true, 1, amount, now(), now() +FROM correct_ug +ON CONFLICT (challenge_id, specifier) DO NOTHING; + +COMMIT; + +-- --------------------------------------------------------------------------- +-- Post-correction verification (read-only; runs after COMMIT). +-- --------------------------------------------------------------------------- +\echo +\echo === trending_results after fix (should be the 10 niche winners) === +SELECT tr.rank, tr.id AS track_id, tr.user_id, u.handle, + au.follower_count AS followers, au.following_count AS following +FROM trending_results tr +LEFT JOIN users u ON u.user_id = tr.user_id AND u.is_current +LEFT JOIN aggregate_user au ON au.user_id = tr.user_id +WHERE tr.type = 'UNDERGROUND_TRACKS' AND tr.version = 'pnagD' AND tr.week = :'week'::date +ORDER BY tr.rank; + +\echo +\echo === tut rewards after fix (ranks 6/8/9 remain the disbursed wrong artists) === +SELECT uc.specifier, uc.user_id, u.handle, uc.amount, + (srd.specifier IS NOT NULL) AS disbursed_onchain +FROM user_challenges uc +LEFT JOIN users u ON u.user_id = uc.user_id AND u.is_current +LEFT JOIN sol_reward_disbursements srd + ON srd.challenge_id = uc.challenge_id AND srd.specifier = uc.specifier +WHERE uc.challenge_id = 'tut' AND uc.specifier LIKE :'week' || ':%' +ORDER BY (split_part(uc.specifier, ':', 2))::int; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index ba9c761b..f9587bc7 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -8425,6 +8425,21 @@ CREATE TABLE public.events ( ); +-- +-- Name: event_routes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.event_routes ( + slug character varying NOT NULL, + owner_id integer NOT NULL, + event_id integer NOT NULL, + is_current boolean NOT NULL, + blockhash character varying NOT NULL, + blocknumber integer NOT NULL, + txhash character varying NOT NULL +); + + -- -- Name: follows; Type: TABLE; Schema: public; Owner: - --