From d42afb1896e70877b3f585a98462037d575f9d2a Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 18 Mar 2026 17:33:53 -0700 Subject: [PATCH 1/3] feat: add GET /grantees/:address/users endpoint Returns paginated full user models for all users who have authorized a given grantee wallet address via the grants table. Supports is_approved and is_revoked filters, and limit/offset pagination. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- api/server.go | 1 + api/swagger/swagger-v1.yaml | 52 +++++++++++++++++ api/v1_grantees_users.go | 68 ++++++++++++++++++++++ api/v1_grantees_users_test.go | 103 ++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 api/v1_grantees_users.go create mode 100644 api/v1_grantees_users_test.go diff --git a/api/server.go b/api/server.go index 8d80c11a..5e659732 100644 --- a/api/server.go +++ b/api/server.go @@ -407,6 +407,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/balance/history", app.v1UsersBalanceHistory) g.Get("/users/:userId/managers", app.v1UsersManagers) g.Get("/users/:userId/managed_users", app.v1UsersManagedUsers) + g.Get("/grantees/:address/users", app.v1GranteeUsers) g.Post("/users/:userId/grants", app.requireAuthMiddleware, app.requireWriteScope, app.postV1UsersGrant) g.Delete("/users/:userId/grants/:address", app.requireAuthMiddleware, app.requireWriteScope, app.deleteV1UsersGrant) g.Post("/users/:userId/managers", app.requireAuthMiddleware, app.requireWriteScope, app.postV1UsersManager) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index b205deb5..6f4ff0a5 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -4687,6 +4687,58 @@ paths: "500": description: Server error content: {} + /grantees/{address}/users: + get: + tags: + - users + description: Get all users who have authorized a particular grantee (developer app) identified by their wallet address. Supports pagination. + operationId: Get Grantee Users + parameters: + - name: address + in: path + description: The wallet address of the grantee (developer app) + 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: is_approved + in: query + description: If true, only return users where the grant has been approved. If false, only return users where the grant was not approved. If omitted, returns all users regardless of approval status. + schema: + type: boolean + - name: is_revoked + in: query + description: If true, only return users where the grant has been revoked. Defaults to false. + schema: + type: boolean + default: false + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/followers_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /users/{id}/balance/history: get: tags: diff --git a/api/v1_grantees_users.go b/api/v1_grantees_users.go new file mode 100644 index 00000000..da40eb3b --- /dev/null +++ b/api/v1_grantees_users.go @@ -0,0 +1,68 @@ +package api + +import ( + "strconv" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +func (app *ApiServer) v1GranteeUsers(c *fiber.Ctx) error { + address := c.Params("address") + + isApproved, err := getOptionalBool(c, "is_approved") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid value for is_approved") + } + + isRevoked, err := strconv.ParseBool(c.Query("is_revoked", "false")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid value for is_revoked") + } + + params := GetUsersParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myId := app.getMyId(c) + + sql := ` + SELECT g.user_id + FROM grants g + WHERE g.grantee_address = @grantee_address + AND g.is_current = true + AND g.is_revoked = @is_revoked + AND (@is_approved::boolean IS NULL OR g.is_approved = @is_approved) + ORDER BY g.created_at DESC + LIMIT @limit + OFFSET @offset + ` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "grantee_address": address, + "is_revoked": isRevoked, + "is_approved": isApproved, + "limit": params.Limit, + "offset": params.Offset, + }) + if err != nil { + return err + } + + userIds, err := pgx.CollectRows(rows, pgx.RowTo[int32]) + if err != nil { + return err + } + + users, err := app.queries.Users(c.Context(), dbv1.GetUsersParams{ + MyID: myId, + Ids: userIds, + }) + if err != nil { + return err + } + + return v1UsersResponse(c, users) +} diff --git a/api/v1_grantees_users_test.go b/api/v1_grantees_users_test.go new file mode 100644 index 00000000..c0c28f8e --- /dev/null +++ b/api/v1_grantees_users_test.go @@ -0,0 +1,103 @@ +package api + +import ( + "testing" + + "api.audius.co/api/dbv1" + "github.com/stretchr/testify/assert" +) + +// grantee address 0x681c616ae836ceca1effe00bd07f2fdbf9a082bc is the wallet of user 100 (authtest1). +// Grants for this address: +// user_id=1, is_approved=false, is_revoked=false +// user_id=2, is_approved=true, is_revoked=false +// user_id=3, is_approved=true, is_revoked=true +// user_id=4, is_approved=false, is_revoked=true + +const testGranteeAddress = "0x681c616ae836ceca1effe00bd07f2fdbf9a082bc" + +// Default params: is_revoked=false, all approval statuses +func TestGetGranteeUsersNoParams(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + status, _ := testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 2, len(response.Data)) +} + +// Only approved grants (is_revoked defaults to false) +func TestGetGranteeUsersApproved(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + status, body := testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users?is_approved=true", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 1, len(response.Data)) + jsonAssert(t, body, map[string]any{ + "data.0.handle": "stereosteve", + }) +} + +// Only unapproved grants (is_revoked defaults to false) +func TestGetGranteeUsersNotApproved(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + status, body := testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users?is_approved=false", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 1, len(response.Data)) + jsonAssert(t, body, map[string]any{ + "data.0.handle": "rayjacobson", + }) +} + +// Revoked grants +func TestGetGranteeUsersRevoked(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + status, _ := testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users?is_revoked=true", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 2, len(response.Data)) +} + +// Grantee address with no grants returns empty list +func TestGetGranteeUsersNotFound(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + status, _ := testGet(t, app, "/v1/grantees/0xdeadbeef/users", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 0, len(response.Data)) +} + +// Pagination: limit=1 should return only 1 user +func TestGetGranteeUsersPagination(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + status, _ := testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users?limit=1", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 1, len(response.Data)) +} + +// Invalid param values should return 400 +func TestGetGranteeUsersInvalidParams(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + + status, _ := testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users?is_approved=invalid", &response) + assert.Equal(t, 400, status) + + status, _ = testGet(t, app, "/v1/grantees/"+testGranteeAddress+"/users?is_revoked=invalid", &response) + assert.Equal(t, 400, status) +} From 77fdab594194e9cdb1b4d71ab2d1a025ed3b94aa Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 18 Mar 2026 17:40:08 -0700 Subject: [PATCH 2/3] fix: normalize grantee address by prepending 0x if missing Allows callers to pass the address with or without the 0x prefix. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- api/v1_grantees_users.go | 4 ++++ api/v1_grantees_users_test.go | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/api/v1_grantees_users.go b/api/v1_grantees_users.go index da40eb3b..8e251fbf 100644 --- a/api/v1_grantees_users.go +++ b/api/v1_grantees_users.go @@ -2,6 +2,7 @@ package api import ( "strconv" + "strings" "api.audius.co/api/dbv1" "github.com/gofiber/fiber/v2" @@ -10,6 +11,9 @@ import ( func (app *ApiServer) v1GranteeUsers(c *fiber.Ctx) error { address := c.Params("address") + if !strings.HasPrefix(address, "0x") { + address = "0x" + address + } isApproved, err := getOptionalBool(c, "is_approved") if err != nil { diff --git a/api/v1_grantees_users_test.go b/api/v1_grantees_users_test.go index c0c28f8e..5edffb19 100644 --- a/api/v1_grantees_users_test.go +++ b/api/v1_grantees_users_test.go @@ -1,6 +1,7 @@ package api import ( + "strings" "testing" "api.audius.co/api/dbv1" @@ -66,6 +67,18 @@ func TestGetGranteeUsersRevoked(t *testing.T) { assert.Equal(t, 2, len(response.Data)) } +// Address without 0x prefix should work the same as with prefix +func TestGetGranteeUsersWithoutHexPrefix(t *testing.T) { + app := testAppWithFixtures(t) + var response struct { + Data []dbv1.User + } + addressWithoutPrefix := strings.TrimPrefix(testGranteeAddress, "0x") + status, _ := testGet(t, app, "/v1/grantees/"+addressWithoutPrefix+"/users", &response) + assert.Equal(t, 200, status) + assert.Equal(t, 2, len(response.Data)) +} + // Grantee address with no grants returns empty list func TestGetGranteeUsersNotFound(t *testing.T) { app := testAppWithFixtures(t) From 674a1fa47ad4b626a13dcccf1a166d9a9e64d4fc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:15:21 -0700 Subject: [PATCH 3/3] Add index on grants table for grantee_address query performance (#731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `GET /grantees/:address/users` endpoint was doing a full table scan on `grants` — the table only had a primary key on `(user_id, txhash, grantee_address)` with no usable index for lookups by `grantee_address`. ## Changes - **`ddl/migrations/0191_grants_grantee_address_idx.sql`** — adds a partial index covering the query's filter and sort columns: ```sql CREATE INDEX IF NOT EXISTS idx_grants_grantee_address ON grants(grantee_address, is_revoked, created_at DESC) WHERE is_current = true; ``` The `WHERE is_current = true` predicate keeps the index small; `is_revoked` covers the boolean filter; `created_at DESC` supports `ORDER BY … LIMIT … OFFSET` pagination without a sort step. - **`sql/01_schema.sql`** — schema snapshot updated to reflect the new index. --- 📍 Connect Copilot coding agent with [Jira](https://gh.io/cca-jira-docs), [Azure Boards](https://gh.io/cca-azure-boards-docs) or [Linear](https://gh.io/cca-linear-docs) to delegate work to Copilot in one click without leaving your project management tool. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rickyrombo <3690498+rickyrombo@users.noreply.github.com> --- ddl/migrations/0191_grants_grantee_address_idx.sql | 7 +++++++ sql/01_schema.sql | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 ddl/migrations/0191_grants_grantee_address_idx.sql diff --git a/ddl/migrations/0191_grants_grantee_address_idx.sql b/ddl/migrations/0191_grants_grantee_address_idx.sql new file mode 100644 index 00000000..adb02c37 --- /dev/null +++ b/ddl/migrations/0191_grants_grantee_address_idx.sql @@ -0,0 +1,7 @@ +begin; + +CREATE INDEX IF NOT EXISTS idx_grants_grantee_address +ON grants(grantee_address, is_revoked, created_at DESC) +WHERE is_current = true; + +commit; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index e237200d..0256c531 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -11096,6 +11096,13 @@ CREATE INDEX idx_fanout_not_deleted ON public.follows USING btree (follower_user CREATE INDEX idx_genre_related_artists ON public.aggregate_user USING btree (dominant_genre, follower_count, user_id); +-- +-- Name: idx_grants_grantee_address; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_grants_grantee_address ON public.grants USING btree (grantee_address, is_revoked, created_at DESC) WHERE (is_current = true); + + -- -- Name: idx_lower_wallet; Type: INDEX; Schema: public; Owner: - --