Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions api/v1_grantees_users.go
Original file line number Diff line number Diff line change
@@ -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, &params); 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot what indexes do we have on this table? is this fast?

`

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)
}
116 changes: 116 additions & 0 deletions api/v1_grantees_users_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions ddl/migrations/0191_grants_grantee_address_idx.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions sql/01_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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: -
--
Expand Down
Loading