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..8e251fbf --- /dev/null +++ b/api/v1_grantees_users.go @@ -0,0 +1,72 @@ +package api + +import ( + "strconv" + "strings" + + "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") + if !strings.HasPrefix(address, "0x") { + address = "0x" + 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..5edffb19 --- /dev/null +++ b/api/v1_grantees_users_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "strings" + "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)) +} + +// 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) + 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) +} 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: - --