Skip to content

Commit 0bb79fc

Browse files
authored
Enriched Referral Program Edition Summaries (#1780)
1 parent 2e306a1 commit 0bb79fc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1127
-241
lines changed

.changeset/swift-trains-move.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@namehash/ens-referrals": minor
3+
"ensapi": patch
4+
---
5+
6+
Add `Exhausted` and `AwardsReview` referral program statuses; add `areAwardsDistributed` to base rules; enrich `/editions` with runtime `status` and `awardPoolRemaining` per edition.

apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,12 @@ export const getEditionsRoute = createRoute({
112112
path: "/editions",
113113
operationId: "getEditions_v1",
114114
tags: ["ENSAwards"],
115-
summary: "Get Edition Config Set (v1)",
115+
summary: "Get Edition Summaries (v1)",
116116
description:
117-
"Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).",
117+
"Returns a summary for each configured referral program edition, including its current status and award-model-specific runtime data. Editions are sorted in descending order by start timestamp (most recent first).",
118118
responses: {
119119
200: {
120-
description: "Successfully retrieved edition config set",
120+
description: "Successfully retrieved edition summaries.",
121121
},
122122
500: {
123123
description: "Internal server error",

apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => (
2828

2929
import {
3030
buildReferralProgramRulesPieSplit,
31-
deserializeReferralProgramEditionConfigSetResponse,
31+
deserializeReferralProgramEditionSummariesResponse,
3232
deserializeReferrerLeaderboardPageResponse,
3333
deserializeReferrerMetricsEditionsResponse,
3434
ReferralProgramAwardModels,
35-
ReferralProgramEditionConfigSetResponseCodes,
3635
type ReferralProgramEditionSlug,
37-
ReferralProgramStatuses,
36+
ReferralProgramEditionStatuses,
37+
ReferralProgramEditionSummariesResponseCodes,
3838
ReferrerEditionMetricsTypeIds,
3939
type ReferrerLeaderboard,
4040
ReferrerLeaderboardPageResponseCodes,
@@ -123,7 +123,7 @@ describe("/v1/ensanalytics", () => {
123123
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
124124
data: {
125125
...populatedReferrerLeaderboard,
126-
status: ReferralProgramStatuses.Active,
126+
status: ReferralProgramEditionStatuses.Active,
127127
pageContext: {
128128
endIndex: 9,
129129
hasNext: true,
@@ -145,7 +145,7 @@ describe("/v1/ensanalytics", () => {
145145
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
146146
data: {
147147
...populatedReferrerLeaderboard,
148-
status: ReferralProgramStatuses.Active,
148+
status: ReferralProgramEditionStatuses.Active,
149149
pageContext: {
150150
endIndex: 19,
151151
hasNext: true,
@@ -166,7 +166,7 @@ describe("/v1/ensanalytics", () => {
166166
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
167167
data: {
168168
...populatedReferrerLeaderboard,
169-
status: ReferralProgramStatuses.Active,
169+
status: ReferralProgramEditionStatuses.Active,
170170
pageContext: {
171171
endIndex: 28,
172172
hasNext: false,
@@ -229,7 +229,7 @@ describe("/v1/ensanalytics", () => {
229229
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
230230
data: {
231231
...emptyReferralLeaderboard,
232-
status: ReferralProgramStatuses.Active,
232+
status: ReferralProgramEditionStatuses.Active,
233233
pageContext: {
234234
hasNext: false,
235235
hasPrev: false,
@@ -369,7 +369,7 @@ describe("/v1/ensanalytics", () => {
369369
referrer: expectedMetrics,
370370
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
371371
accurateAsOf: expectedAccurateAsOf,
372-
status: ReferralProgramStatuses.Active,
372+
status: ReferralProgramEditionStatuses.Active,
373373
},
374374
"2026-03": {
375375
awardModel: populatedReferrerLeaderboard.awardModel,
@@ -378,7 +378,7 @@ describe("/v1/ensanalytics", () => {
378378
referrer: expectedMetrics,
379379
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
380380
accurateAsOf: expectedAccurateAsOf,
381-
status: ReferralProgramStatuses.Active,
381+
status: ReferralProgramEditionStatuses.Active,
382382
},
383383
},
384384
} satisfies ReferrerMetricsEditionsResponseOk;
@@ -824,6 +824,7 @@ describe("/v1/ensanalytics", () => {
824824
parseTimestamp("2025-12-31T23:59:59Z"),
825825
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
826826
new URL("https://example.com/rules"),
827+
false,
827828
),
828829
},
829830
],
@@ -839,6 +840,7 @@ describe("/v1/ensanalytics", () => {
839840
parseTimestamp("2026-03-31T23:59:59Z"),
840841
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
841842
new URL("https://example.com/rules"),
843+
false,
842844
),
843845
},
844846
],
@@ -854,6 +856,7 @@ describe("/v1/ensanalytics", () => {
854856
parseTimestamp("2026-06-30T23:59:59Z"),
855857
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
856858
new URL("https://example.com/rules"),
859+
false,
857860
),
858861
},
859862
],
@@ -867,24 +870,40 @@ describe("/v1/ensanalytics", () => {
867870
},
868871
);
869872

870-
// Mock caches middleware (needed by middleware chain)
873+
// Mock caches middleware with a cache for each edition
874+
const mockEditionsCaches = new Map<ReferralProgramEditionSlug, SWRCache<ReferrerLeaderboard>>(
875+
[
876+
[
877+
"2025-12",
878+
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
879+
],
880+
[
881+
"2026-03",
882+
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
883+
],
884+
[
885+
"2026-06",
886+
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
887+
],
888+
],
889+
);
871890
vi.mocked(
872891
editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware,
873892
).mockImplementation(async (c, next) => {
874-
c.set("referralLeaderboardEditionsCaches", new Map());
893+
c.set("referralLeaderboardEditionsCaches", mockEditionsCaches);
875894
return await next();
876895
});
877896

878897
// Act: send test request
879898
const httpResponse = await app.request("/editions");
880899
const responseData = await httpResponse.json();
881-
const response = deserializeReferralProgramEditionConfigSetResponse(responseData);
900+
const response = deserializeReferralProgramEditionSummariesResponse(responseData);
882901

883902
// Assert: response contains all editions sorted by start timestamp descending
884903
expect(httpResponse.status).toBe(200);
885-
expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Ok);
904+
expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Ok);
886905

887-
if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) {
906+
if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Ok) {
888907
expect(response.data.editions).toHaveLength(3);
889908

890909
// Verify sorting: most recent start time first
@@ -920,13 +939,13 @@ describe("/v1/ensanalytics", () => {
920939
// Act: send test request
921940
const httpResponse = await app.request("/editions");
922941
const responseData = await httpResponse.json();
923-
const response = deserializeReferralProgramEditionConfigSetResponse(responseData);
942+
const response = deserializeReferralProgramEditionSummariesResponse(responseData);
924943

925944
// Assert: response is error
926945
expect(httpResponse.status).toBe(503);
927-
expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Error);
946+
expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Error);
928947

929-
if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) {
948+
if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Error) {
930949
expect(response.error).toBe("Service Unavailable");
931950
expect(response.errorMessage).toContain("currently unavailable");
932951
}

apps/ensapi/src/handlers/ensanalytics-api-v1.ts

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import {
2+
buildEditionSummary,
23
getReferrerEditionMetrics,
34
getReferrerLeaderboardPage,
4-
type ReferralProgramEditionConfigSetResponse,
5-
ReferralProgramEditionConfigSetResponseCodes,
65
type ReferralProgramEditionSlug,
6+
type ReferralProgramEditionSummariesResponse,
7+
ReferralProgramEditionSummariesResponseCodes,
78
type ReferrerLeaderboard,
89
type ReferrerLeaderboardPageResponse,
910
ReferrerLeaderboardPageResponseCodes,
1011
type ReferrerMetricsEditionsData,
1112
type ReferrerMetricsEditionsResponse,
1213
ReferrerMetricsEditionsResponseCodes,
13-
serializeReferralProgramEditionConfigSetResponse,
14+
serializeReferralProgramEditionSummariesResponse,
1415
serializeReferrerLeaderboardPageResponse,
1516
serializeReferrerMetricsEditionsResponse,
1617
} from "@namehash/ens-referrals/v1";
@@ -235,7 +236,7 @@ app.openapi(getReferrerDetailRoute, async (c) => {
235236
}
236237
});
237238

238-
// Get configured edition config set
239+
// Get edition summaries
239240
app.openapi(getEditionsRoute, async (c) => {
240241
// context must be set by the required middleware
241242
if (c.var.referralProgramEditionConfigSet === undefined) {
@@ -244,6 +245,12 @@ app.openapi(getEditionsRoute, async (c) => {
244245
);
245246
}
246247

248+
if (c.var.referralLeaderboardEditionsCaches === undefined) {
249+
throw new Error(
250+
`Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`,
251+
);
252+
}
253+
247254
try {
248255
// Check if edition config set failed to load
249256
if (c.var.referralProgramEditionConfigSet instanceof Error) {
@@ -252,27 +259,82 @@ app.openapi(getEditionsRoute, async (c) => {
252259
"Referral program edition config set failed to load",
253260
);
254261
return c.json(
255-
serializeReferralProgramEditionConfigSetResponse({
256-
responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
262+
serializeReferralProgramEditionSummariesResponse({
263+
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
257264
error: "Service Unavailable",
258265
errorMessage: "Referral program configuration is currently unavailable.",
259-
} satisfies ReferralProgramEditionConfigSetResponse),
266+
} satisfies ReferralProgramEditionSummariesResponse),
260267
503,
261268
);
262269
}
263270

264-
// Convert Map to array and sort by start timestamp descending
265-
const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort(
271+
// Check if leaderboard caches failed to load
272+
if (c.var.referralLeaderboardEditionsCaches instanceof Error) {
273+
logger.error(
274+
{ error: c.var.referralLeaderboardEditionsCaches },
275+
"Referral program leaderboard caches failed to load",
276+
);
277+
return c.json(
278+
serializeReferralProgramEditionSummariesResponse({
279+
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
280+
error: "Service Unavailable",
281+
errorMessage: "Referral program leaderboard data is currently unavailable.",
282+
} satisfies ReferralProgramEditionSummariesResponse),
283+
503,
284+
);
285+
}
286+
287+
// Sort edition configs by start timestamp descending
288+
const editionConfigs = Array.from(c.var.referralProgramEditionConfigSet.values()).sort(
266289
(a, b) => b.rules.startTime - a.rules.startTime,
267290
);
268291

292+
// Read all leaderboard caches in parallel, keeping config colocated with its leaderboard
293+
const leaderboardCaches = c.var.referralLeaderboardEditionsCaches;
294+
const results = await Promise.all(
295+
editionConfigs.map(async (config) => {
296+
const cache = leaderboardCaches.get(config.slug);
297+
if (!cache) throw new Error(`Invariant: edition cache for ${config.slug} should exist`);
298+
return { config, leaderboard: await cache.read() };
299+
}),
300+
);
301+
302+
// Partition into failures and successes in one pass
303+
const failedSlugs: ReferralProgramEditionSlug[] = [];
304+
const valid: {
305+
config: (typeof results)[number]["config"];
306+
leaderboard: ReferrerLeaderboard;
307+
}[] = [];
308+
for (const { config, leaderboard } of results) {
309+
if (leaderboard instanceof Error) {
310+
failedSlugs.push(config.slug);
311+
} else {
312+
valid.push({ config, leaderboard });
313+
}
314+
}
315+
316+
if (failedSlugs.length > 0) {
317+
return c.json(
318+
serializeReferralProgramEditionSummariesResponse({
319+
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
320+
error: "Service Unavailable",
321+
errorMessage: `Leaderboard data not available for edition(s): ${failedSlugs.join(", ")}`,
322+
} satisfies ReferralProgramEditionSummariesResponse),
323+
503,
324+
);
325+
}
326+
327+
const editions = valid.map(({ config, leaderboard }) =>
328+
buildEditionSummary(config, leaderboard),
329+
);
330+
269331
return c.json(
270-
serializeReferralProgramEditionConfigSetResponse({
271-
responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok,
332+
serializeReferralProgramEditionSummariesResponse({
333+
responseCode: ReferralProgramEditionSummariesResponseCodes.Ok,
272334
data: {
273335
editions,
274336
},
275-
} satisfies ReferralProgramEditionConfigSetResponse),
337+
} satisfies ReferralProgramEditionSummariesResponse),
276338
);
277339
} catch (error) {
278340
logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint");
@@ -281,11 +343,11 @@ app.openapi(getEditionsRoute, async (c) => {
281343
? error.message
282344
: "An unexpected error occurred while processing your request";
283345
return c.json(
284-
serializeReferralProgramEditionConfigSetResponse({
285-
responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
346+
serializeReferralProgramEditionSummariesResponse({
347+
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
286348
error: "Internal server error",
287349
errorMessage,
288-
} satisfies ReferralProgramEditionConfigSetResponse),
350+
} satisfies ReferralProgramEditionSummariesResponse),
289351
500,
290352
);
291353
}

apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/get-referrer-leaderboard-v1.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const rules = buildReferralProgramRulesPieSplit(
2626
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
2727
},
2828
new URL("https://example.com/rules"),
29+
false,
2930
);
3031

3132
const accurateAsOf = parseTimestamp("2025-11-30T23:59:59Z");

apps/ensapi/src/lib/ensanalytics/referrer-leaderboard/mocks-v1.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
ReferralProgramAwardModels,
3-
ReferralProgramStatuses,
3+
ReferralProgramEditionStatuses,
44
type ReferrerLeaderboardPagePieSplit,
55
ReferrerLeaderboardPageResponseCodes,
66
type ReferrerLeaderboardPageResponseOk,
@@ -188,6 +188,7 @@ export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = {
188188
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
189189
},
190190
rulesUrl: new URL("https://example.com/rules"),
191+
areAwardsDistributed: false,
191192
},
192193
aggregatedMetrics: {
193194
grandTotalReferrals: 0,
@@ -213,6 +214,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = {
213214
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
214215
},
215216
rulesUrl: new URL("https://example.com/rules"),
217+
areAwardsDistributed: false,
216218
},
217219
aggregatedMetrics: {
218220
grandTotalReferrals: 68,
@@ -705,6 +707,7 @@ export const referrerLeaderboardPageResponseOk = {
705707
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
706708
},
707709
rulesUrl: new URL("https://example.com/rules"),
710+
areAwardsDistributed: false,
708711
},
709712
referrers: [
710713
{
@@ -1102,7 +1105,7 @@ export const referrerLeaderboardPageResponseOk = {
11021105
startIndex: 0,
11031106
endIndex: 28,
11041107
},
1105-
status: ReferralProgramStatuses.Active,
1108+
status: ReferralProgramEditionStatuses.Active,
11061109
accurateAsOf: 1735689600,
11071110
} satisfies ReferrerLeaderboardPagePieSplit,
11081111
} satisfies ReferrerLeaderboardPageResponseOk;

docs/docs.ensnode.io/ensapi-openapi.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,10 +1036,10 @@
10361036
"get": {
10371037
"operationId": "getEditions_v1",
10381038
"tags": ["ENSAwards"],
1039-
"summary": "Get Edition Config Set (v1)",
1040-
"description": "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).",
1039+
"summary": "Get Edition Summaries (v1)",
1040+
"description": "Returns a summary for each configured referral program edition, including its current status and award-model-specific runtime data. Editions are sorted in descending order by start timestamp (most recent first).",
10411041
"responses": {
1042-
"200": { "description": "Successfully retrieved edition config set" },
1042+
"200": { "description": "Successfully retrieved edition summaries." },
10431043
"500": { "description": "Internal server error" },
10441044
"503": { "description": "Service unavailable" }
10451045
}

0 commit comments

Comments
 (0)