Skip to content

Commit 9e0f646

Browse files
authored
Merge pull request #47 from superfluid-org/claude/add-campaign-accounts-endpoint-siYhG
Add campaign accounts leaderboard endpoint
2 parents 581cd94 + 093aaed commit 9e0f646

File tree

4 files changed

+304
-1
lines changed

4 files changed

+304
-1
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { CampaignAccountsResponse } from "@/domains/points/types"
2+
import { getPayloadInstance } from "@/payload"
3+
4+
const VALID_ORDER_BY = ["totalPoints", "eventCount", "lastEventAt"] as const
5+
6+
/**
7+
* GET /points/accounts?campaignId=42
8+
* Query all accounts in a campaign with their point balances.
9+
* Supports sorting and pagination for leaderboard display.
10+
*
11+
* Query params:
12+
* - campaignId (required): Campaign ID
13+
* - orderBy (optional): totalPoints | eventCount | lastEventAt (default: totalPoints)
14+
* - order (optional): asc | desc (default: desc)
15+
* - limit (optional): 1-100 (default: 50)
16+
* - page (optional): positive integer (default: 1)
17+
*/
18+
export const GET = async (request: Request): Promise<Response> => {
19+
try {
20+
const url = new URL(request.url)
21+
22+
// Get campaignId parameter (required, must be numeric)
23+
const campaignIdParam = url.searchParams.get("campaignId")
24+
if (!campaignIdParam) {
25+
return Response.json({ message: "Missing required query parameter: campaignId" }, { status: 400 })
26+
}
27+
28+
const campaignId = parseInt(campaignIdParam, 10)
29+
if (Number.isNaN(campaignId) || campaignId <= 0) {
30+
return Response.json({ message: "campaignId must be a positive integer" }, { status: 400 })
31+
}
32+
33+
// Parse and validate orderBy
34+
const orderByParam = url.searchParams.get("orderBy")
35+
let orderBy: (typeof VALID_ORDER_BY)[number] = "totalPoints"
36+
if (orderByParam) {
37+
if (!VALID_ORDER_BY.includes(orderByParam as (typeof VALID_ORDER_BY)[number])) {
38+
return Response.json(
39+
{ message: `orderBy must be one of: ${VALID_ORDER_BY.join(", ")}` },
40+
{ status: 400 },
41+
)
42+
}
43+
orderBy = orderByParam as (typeof VALID_ORDER_BY)[number]
44+
}
45+
46+
// Parse and validate order
47+
const orderParam = url.searchParams.get("order")
48+
let order: "asc" | "desc" = "desc"
49+
if (orderParam) {
50+
if (orderParam !== "asc" && orderParam !== "desc") {
51+
return Response.json({ message: "order must be 'asc' or 'desc'" }, { status: 400 })
52+
}
53+
order = orderParam
54+
}
55+
56+
// Parse and validate limit
57+
const limitParam = url.searchParams.get("limit")
58+
let limit = 50
59+
if (limitParam) {
60+
const parsed = Number.parseInt(limitParam, 10)
61+
if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
62+
return Response.json({ message: "limit must be between 1 and 100" }, { status: 400 })
63+
}
64+
limit = parsed
65+
}
66+
67+
// Parse and validate page
68+
const pageParam = url.searchParams.get("page")
69+
let page = 1
70+
if (pageParam) {
71+
const parsed = Number.parseInt(pageParam, 10)
72+
if (Number.isNaN(parsed) || parsed < 1) {
73+
return Response.json({ message: "page must be a positive integer" }, { status: 400 })
74+
}
75+
page = parsed
76+
}
77+
78+
// Verify campaign exists
79+
const payload = await getPayloadInstance()
80+
const campaignResult = await payload.find({
81+
collection: "campaigns",
82+
where: { id: { equals: campaignId } },
83+
limit: 1,
84+
})
85+
86+
if (campaignResult.docs.length === 0) {
87+
return Response.json({ message: "Campaign not found" }, { status: 404 })
88+
}
89+
90+
// Build Payload sort string: prefix with "-" for descending
91+
const sort = order === "desc" ? `-${orderBy}` : orderBy
92+
93+
// Query point balances for all accounts in the campaign
94+
const result = await payload.find({
95+
collection: "point-balances",
96+
where: {
97+
campaign: { equals: campaignId },
98+
},
99+
sort,
100+
limit,
101+
page,
102+
})
103+
104+
const response: CampaignAccountsResponse = {
105+
accounts: result.docs.map((doc) => ({
106+
account: doc.account,
107+
totalPoints: doc.totalPoints,
108+
eventCount: doc.eventCount,
109+
lastEventAt: doc.lastEventAt ?? null,
110+
})),
111+
pagination: {
112+
page: result.page ?? 1,
113+
limit: result.limit,
114+
totalDocs: result.totalDocs,
115+
totalPages: result.totalPages,
116+
hasNextPage: result.hasNextPage,
117+
hasPrevPage: result.hasPrevPage,
118+
},
119+
}
120+
121+
return Response.json(response)
122+
} catch (error) {
123+
console.error("Failed to query campaign accounts:", error)
124+
125+
return Response.json(
126+
{
127+
message: error instanceof Error ? error.message : "Failed to query campaign accounts",
128+
},
129+
{ status: 500 },
130+
)
131+
}
132+
}

cms/src/domains/points/api/registry.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
BalanceBatchResponseSchema,
66
BalancePostBodySchema,
77
BalanceQuerySchema,
8+
CampaignAccountSchema,
9+
CampaignAccountsQuerySchema,
10+
CampaignAccountsResponseSchema,
811
EventBalanceQuerySchema,
912
EventBalanceResponseSchema,
1013
EventsQuerySchema,
@@ -34,6 +37,8 @@ pointsRegistry.register("SignedBalanceResponse", SignedBalanceResponseSchema)
3437
pointsRegistry.register("SignedBalanceBatchResponse", SignedBalanceBatchResponseSchema)
3538
pointsRegistry.register("BalanceBatchResponse", BalanceBatchResponseSchema)
3639
pointsRegistry.register("EventBalanceResponse", EventBalanceResponseSchema)
40+
pointsRegistry.register("CampaignAccount", CampaignAccountSchema)
41+
pointsRegistry.register("CampaignAccountsResponse", CampaignAccountsResponseSchema)
3742
pointsRegistry.register("ApiError", ApiErrorSchema)
3843

3944
// Register security scheme
@@ -292,6 +297,79 @@ This endpoint queries the raw point events and aggregates on-demand, unlike \`/b
292297
},
293298
})
294299

300+
// ============================================
301+
// GET /points/accounts
302+
// ============================================
303+
pointsRegistry.registerPath({
304+
method: "get",
305+
path: "/points/accounts",
306+
summary: "Get campaign accounts (leaderboard)",
307+
description:
308+
"Retrieves all accounts in a campaign with their point balances, event counts, and last event timestamps. Results are paginated and sortable, making this ideal for building leaderboard views.",
309+
tags: ["Balance"],
310+
request: {
311+
query: CampaignAccountsQuerySchema,
312+
},
313+
responses: {
314+
200: {
315+
description: "Campaign accounts retrieved successfully",
316+
content: {
317+
"application/json": {
318+
schema: CampaignAccountsResponseSchema,
319+
example: {
320+
accounts: [
321+
{
322+
account: "0x1234567890abcdef1234567890abcdef12345678",
323+
totalPoints: 1500,
324+
eventCount: 42,
325+
lastEventAt: "2026-01-15T12:00:00.000Z",
326+
},
327+
{
328+
account: "0xabcdef1234567890abcdef1234567890abcdef12",
329+
totalPoints: 750,
330+
eventCount: 18,
331+
lastEventAt: "2026-01-14T08:30:00.000Z",
332+
},
333+
],
334+
pagination: {
335+
page: 1,
336+
limit: 50,
337+
totalDocs: 150,
338+
totalPages: 3,
339+
hasNextPage: true,
340+
hasPrevPage: false,
341+
},
342+
},
343+
},
344+
},
345+
},
346+
400: {
347+
description: "Invalid request (missing/invalid campaignId, invalid orderBy, or invalid pagination)",
348+
content: {
349+
"application/json": {
350+
schema: ApiErrorSchema,
351+
},
352+
},
353+
},
354+
404: {
355+
description: "Campaign not found",
356+
content: {
357+
"application/json": {
358+
schema: ApiErrorSchema,
359+
},
360+
},
361+
},
362+
500: {
363+
description: "Internal server error",
364+
content: {
365+
"application/json": {
366+
schema: ApiErrorSchema,
367+
},
368+
},
369+
},
370+
},
371+
})
372+
295373
// ============================================
296374
// GET /points/signed-balance
297375
// ============================================
@@ -789,7 +867,7 @@ npx @hey-api/openapi-ts -i https://cms.superfluid.pro/points/openapi.json \\
789867
790868
### Authentication
791869
792-
**Query Endpoints** (\`/balance\`, \`/signed-balance\`, \`/events\`): Public access, no authentication required. Use numeric \`campaignId\` as query parameter.
870+
**Query Endpoints** (\`/balance\`, \`/signed-balance\`, \`/events\`, \`/accounts\`): Public access, no authentication required. Use numeric \`campaignId\` as query parameter.
793871
794872
**Push Endpoint** (\`/push\`): Requires API key in the \`X-API-Key\` header. API keys are scoped to a specific campaign.
795873
@@ -877,10 +955,17 @@ const { data: campaigns } = await client.POST('/points/balance-batch', {
877955
body: { campaignIds: [1, 2, 3], account: '0x1234...' }
878956
});
879957
// { address, campaignIds, points, warnings? }
958+
959+
// Leaderboard - all accounts ranked by points (GET)
960+
const { data: leaderboard } = await client.GET('/points/accounts', {
961+
params: { query: { campaignId: 42, orderBy: 'totalPoints', order: 'desc' } }
962+
});
963+
// { accounts: [{ account, totalPoints, eventCount, lastEventAt }], pagination }
880964
\`\`\`
881965
882966
> **Note:** Stack.so only offered \`getSignedPointsBatch()\` for querying multiple campaigns (signed).
883967
> The unsigned \`/points/balance-batch\` endpoint is a new capability.
968+
> The \`/points/accounts\` endpoint is a new leaderboard capability with no Stack.so equivalent.
884969
885970
### Signed Points (On-Chain Verification)
886971

cms/src/domains/points/api/schemas.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,74 @@ export const PushResponseSchema = z
379379
description: "Response from push endpoint (202 Accepted)",
380380
})
381381

382+
// ============================================
383+
// Campaign Accounts Endpoint Schemas
384+
// ============================================
385+
386+
export const CampaignAccountsQuerySchema = z
387+
.object({
388+
campaignId: z.coerce.number().int().positive().openapi({
389+
example: 42,
390+
description: "Campaign ID",
391+
}),
392+
orderBy: z.enum(["totalPoints", "eventCount", "lastEventAt"]).optional().openapi({
393+
example: "totalPoints",
394+
description: "Field to order results by (default: totalPoints)",
395+
}),
396+
order: z.enum(["asc", "desc"]).optional().openapi({
397+
example: "desc",
398+
description: "Sort order (default: desc)",
399+
}),
400+
limit: z.coerce.number().int().min(1).max(100).optional().openapi({
401+
example: 50,
402+
description: "Number of results per page (1-100, default: 50)",
403+
}),
404+
page: z.coerce.number().int().positive().optional().openapi({
405+
example: 1,
406+
description: "Page number (default: 1)",
407+
}),
408+
})
409+
.openapi({
410+
title: "CampaignAccountsQuery",
411+
description: "Query parameters for campaign accounts (leaderboard) endpoint",
412+
})
413+
414+
export const CampaignAccountSchema = z
415+
.object({
416+
account: z.string().openapi({
417+
example: "0x1234567890abcdef1234567890abcdef12345678",
418+
description: "Ethereum wallet address",
419+
}),
420+
totalPoints: z.number().openapi({
421+
example: 1500,
422+
description: "Total accumulated points in the campaign",
423+
}),
424+
eventCount: z.number().openapi({
425+
example: 42,
426+
description: "Total number of point events for this account",
427+
}),
428+
lastEventAt: z.string().nullable().openapi({
429+
example: "2026-01-15T12:00:00.000Z",
430+
description: "ISO 8601 timestamp of the most recent event, or null if no events",
431+
}),
432+
})
433+
.openapi({
434+
title: "CampaignAccount",
435+
description: "Account entry with point balance and metadata for a campaign",
436+
})
437+
438+
export const CampaignAccountsResponseSchema = z
439+
.object({
440+
accounts: z.array(CampaignAccountSchema).openapi({
441+
description: "List of accounts with their point balances",
442+
}),
443+
pagination: PaginationSchema,
444+
})
445+
.openapi({
446+
title: "CampaignAccountsResponse",
447+
description: "Paginated list of campaign accounts for leaderboard display",
448+
})
449+
382450
// ============================================
383451
// Signed Balance Endpoint Schemas
384452
// ============================================

cms/src/domains/points/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,21 @@ export const eventBalanceResponseSchema = z.object({
8888
})
8989

9090
export type EventBalanceResponse = z.infer<typeof eventBalanceResponseSchema>
91+
92+
// Campaign account response (for leaderboard)
93+
export const campaignAccountResponseSchema = z.object({
94+
account: z.string(),
95+
totalPoints: z.number(),
96+
eventCount: z.number(),
97+
lastEventAt: z.string().nullable(),
98+
})
99+
100+
export type CampaignAccountResponse = z.infer<typeof campaignAccountResponseSchema>
101+
102+
// Campaign accounts list response
103+
export const campaignAccountsResponseSchema = z.object({
104+
accounts: z.array(campaignAccountResponseSchema),
105+
pagination: paginationSchema,
106+
})
107+
108+
export type CampaignAccountsResponse = z.infer<typeof campaignAccountsResponseSchema>

0 commit comments

Comments
 (0)