Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions .changeset/swift-trains-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@namehash/ens-referrals": minor
Comment thread
Goader marked this conversation as resolved.
Comment thread
Goader marked this conversation as resolved.
"ensapi": patch
---

Add `Exhausted` and `AwardsReview` referral program statuses; add `areAwardsDistributed` to base rules; enrich `/editions` with runtime `status` and `awardPoolRemaining` per edition.
Comment thread
Goader marked this conversation as resolved.
Comment thread
Goader marked this conversation as resolved.
6 changes: 3 additions & 3 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,12 @@ export const getEditionsRoute = createRoute({
path: "/editions",
operationId: "getEditions_v1",
tags: ["ENSAwards"],
summary: "Get Edition Config Set (v1)",
summary: "Get Edition Summaries (v1)",
description:
"Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).",
"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).",
responses: {
200: {
description: "Successfully retrieved edition config set",
description: "Successfully retrieved edition summaries.",
Comment thread
lightwalker-eth marked this conversation as resolved.
},
500: {
description: "Internal server error",
Expand Down
39 changes: 29 additions & 10 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => (

import {
buildReferralProgramRulesPieSplit,
deserializeReferralProgramEditionConfigSetResponse,
deserializeReferralProgramEditionSummariesResponse,
deserializeReferrerLeaderboardPageResponse,
deserializeReferrerMetricsEditionsResponse,
ReferralProgramAwardModels,
ReferralProgramEditionConfigSetResponseCodes,
type ReferralProgramEditionSlug,
ReferralProgramEditionSummariesResponseCodes,
ReferralProgramStatuses,
ReferrerEditionMetricsTypeIds,
type ReferrerLeaderboard,
Expand Down Expand Up @@ -824,6 +824,7 @@ describe("/v1/ensanalytics", () => {
parseTimestamp("2025-12-31T23:59:59Z"),
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
new URL("https://example.com/rules"),
false,
),
},
],
Expand All @@ -839,6 +840,7 @@ describe("/v1/ensanalytics", () => {
parseTimestamp("2026-03-31T23:59:59Z"),
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
new URL("https://example.com/rules"),
false,
),
},
],
Expand All @@ -854,6 +856,7 @@ describe("/v1/ensanalytics", () => {
parseTimestamp("2026-06-30T23:59:59Z"),
{ chainId: 1, address: "0x0000000000000000000000000000000000000000" },
new URL("https://example.com/rules"),
false,
),
},
],
Expand All @@ -867,24 +870,40 @@ describe("/v1/ensanalytics", () => {
},
);

// Mock caches middleware (needed by middleware chain)
// Mock caches middleware with a cache for each edition
const mockEditionsCaches = new Map<ReferralProgramEditionSlug, SWRCache<ReferrerLeaderboard>>(
[
[
"2025-12",
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
],
[
"2026-03",
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
],
[
"2026-06",
{ read: async () => emptyReferralLeaderboard } as SWRCache<ReferrerLeaderboard>,
],
],
);
vi.mocked(
editionsCachesMiddleware.referralLeaderboardEditionsCachesMiddleware,
).mockImplementation(async (c, next) => {
c.set("referralLeaderboardEditionsCaches", new Map());
c.set("referralLeaderboardEditionsCaches", mockEditionsCaches);
return await next();
});

// Act: send test request
const httpResponse = await app.request("/editions");
const responseData = await httpResponse.json();
const response = deserializeReferralProgramEditionConfigSetResponse(responseData);
const response = deserializeReferralProgramEditionSummariesResponse(responseData);

// Assert: response contains all editions sorted by start timestamp descending
expect(httpResponse.status).toBe(200);
expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Ok);
expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Ok);

if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Ok) {
if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Ok) {
expect(response.data.editions).toHaveLength(3);

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

// Assert: response is error
expect(httpResponse.status).toBe(503);
expect(response.responseCode).toBe(ReferralProgramEditionConfigSetResponseCodes.Error);
expect(response.responseCode).toBe(ReferralProgramEditionSummariesResponseCodes.Error);

if (response.responseCode === ReferralProgramEditionConfigSetResponseCodes.Error) {
if (response.responseCode === ReferralProgramEditionSummariesResponseCodes.Error) {
expect(response.error).toBe("Service Unavailable");
expect(response.errorMessage).toContain("currently unavailable");
}
Expand Down
92 changes: 77 additions & 15 deletions apps/ensapi/src/handlers/ensanalytics-api-v1.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import {
buildEditionSummary,
getReferrerEditionMetrics,
getReferrerLeaderboardPage,
type ReferralProgramEditionConfigSetResponse,
ReferralProgramEditionConfigSetResponseCodes,
type ReferralProgramEditionSlug,
type ReferralProgramEditionSummariesResponse,
ReferralProgramEditionSummariesResponseCodes,
type ReferrerLeaderboard,
type ReferrerLeaderboardPageResponse,
ReferrerLeaderboardPageResponseCodes,
type ReferrerMetricsEditionsData,
type ReferrerMetricsEditionsResponse,
ReferrerMetricsEditionsResponseCodes,
serializeReferralProgramEditionConfigSetResponse,
serializeReferralProgramEditionSummariesResponse,
serializeReferrerLeaderboardPageResponse,
serializeReferrerMetricsEditionsResponse,
} from "@namehash/ens-referrals/v1";
Expand Down Expand Up @@ -235,7 +236,7 @@ app.openapi(getReferrerDetailRoute, async (c) => {
}
});

// Get configured edition config set
// Get edition summaries
app.openapi(getEditionsRoute, async (c) => {
// context must be set by the required middleware
Comment thread
Goader marked this conversation as resolved.
if (c.var.referralProgramEditionConfigSet === undefined) {
Expand All @@ -244,6 +245,12 @@ app.openapi(getEditionsRoute, async (c) => {
);
}

if (c.var.referralLeaderboardEditionsCaches === undefined) {
throw new Error(
`Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`,
);
}

try {
// Check if edition config set failed to load
if (c.var.referralProgramEditionConfigSet instanceof Error) {
Expand All @@ -252,27 +259,82 @@ app.openapi(getEditionsRoute, async (c) => {
"Referral program edition config set failed to load",
);
return c.json(
serializeReferralProgramEditionConfigSetResponse({
responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
serializeReferralProgramEditionSummariesResponse({
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
error: "Service Unavailable",
errorMessage: "Referral program configuration is currently unavailable.",
} satisfies ReferralProgramEditionConfigSetResponse),
} satisfies ReferralProgramEditionSummariesResponse),
503,
);
}

// Convert Map to array and sort by start timestamp descending
const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort(
// Check if leaderboard caches failed to load
if (c.var.referralLeaderboardEditionsCaches instanceof Error) {
logger.error(
{ error: c.var.referralLeaderboardEditionsCaches },
"Referral program leaderboard caches failed to load",
);
return c.json(
serializeReferralProgramEditionSummariesResponse({
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
error: "Service Unavailable",
errorMessage: "Referral program leaderboard data is currently unavailable.",
} satisfies ReferralProgramEditionSummariesResponse),
503,
);
}

// Sort edition configs by start timestamp descending
const editionConfigs = Array.from(c.var.referralProgramEditionConfigSet.values()).sort(
(a, b) => b.rules.startTime - a.rules.startTime,
);

// Read all leaderboard caches in parallel, keeping config colocated with its leaderboard
const leaderboardCaches = c.var.referralLeaderboardEditionsCaches;
const results = await Promise.all(
editionConfigs.map(async (config) => {
const cache = leaderboardCaches.get(config.slug);
if (!cache) throw new Error(`Invariant: edition cache for ${config.slug} should exist`);
return { config, leaderboard: await cache.read() };
}),
);

// Partition into failures and successes in one pass
const failedSlugs: ReferralProgramEditionSlug[] = [];
const valid: {
config: (typeof results)[number]["config"];
leaderboard: ReferrerLeaderboard;
}[] = [];
for (const { config, leaderboard } of results) {
if (leaderboard instanceof Error) {
failedSlugs.push(config.slug);
} else {
valid.push({ config, leaderboard });
}
}

if (failedSlugs.length > 0) {
return c.json(
serializeReferralProgramEditionSummariesResponse({
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
error: "Service Unavailable",
errorMessage: `Leaderboard data not available for edition(s): ${failedSlugs.join(", ")}`,
} satisfies ReferralProgramEditionSummariesResponse),
503,
);
}

const editions = valid.map(({ config, leaderboard }) =>
buildEditionSummary(config, leaderboard),
);

return c.json(
serializeReferralProgramEditionConfigSetResponse({
responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok,
serializeReferralProgramEditionSummariesResponse({
responseCode: ReferralProgramEditionSummariesResponseCodes.Ok,
data: {
editions,
},
} satisfies ReferralProgramEditionConfigSetResponse),
} satisfies ReferralProgramEditionSummariesResponse),
);
} catch (error) {
logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint");
Expand All @@ -281,11 +343,11 @@ app.openapi(getEditionsRoute, async (c) => {
? error.message
: "An unexpected error occurred while processing your request";
return c.json(
serializeReferralProgramEditionConfigSetResponse({
responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
serializeReferralProgramEditionSummariesResponse({
responseCode: ReferralProgramEditionSummariesResponseCodes.Error,
error: "Internal server error",
errorMessage,
} satisfies ReferralProgramEditionConfigSetResponse),
} satisfies ReferralProgramEditionSummariesResponse),
500,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const rules = buildReferralProgramRulesPieSplit(
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
new URL("https://example.com/rules"),
false,
);

const accurateAsOf = parseTimestamp("2025-11-30T23:59:59Z");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export const emptyReferralLeaderboard: ReferrerLeaderboardPieSplit = {
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
rulesUrl: new URL("https://example.com/rules"),
areAwardsDistributed: false,
},
aggregatedMetrics: {
grandTotalReferrals: 0,
Expand All @@ -213,6 +214,7 @@ export const populatedReferrerLeaderboard: ReferrerLeaderboardPieSplit = {
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
rulesUrl: new URL("https://example.com/rules"),
areAwardsDistributed: false,
},
aggregatedMetrics: {
grandTotalReferrals: 68,
Expand Down Expand Up @@ -705,6 +707,7 @@ export const referrerLeaderboardPageResponseOk = {
address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
},
rulesUrl: new URL("https://example.com/rules"),
areAwardsDistributed: false,
},
referrers: [
{
Expand Down
18 changes: 9 additions & 9 deletions packages/ens-referrals/src/v1/api/deserialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import { prettifyError } from "zod/v4";

import type { ReferralProgramEditionConfig } from "../edition";
import type {
SerializedReferralProgramEditionConfigSetResponse,
SerializedReferralProgramEditionSummariesResponse,
SerializedReferrerLeaderboardPageResponse,
SerializedReferrerMetricsEditionsResponse,
} from "./serialized-types";
import type {
ReferralProgramEditionConfigSetResponse,
ReferralProgramEditionSummariesResponse,
ReferrerLeaderboardPageResponse,
ReferrerMetricsEditionsResponse,
} from "./types";
import {
makeReferralProgramEditionConfigSetArraySchema,
makeReferralProgramEditionConfigSetResponseSchema,
makeReferralProgramEditionSummariesResponseSchema,
makeReferrerLeaderboardPageResponseSchema,
makeReferrerMetricsEditionsResponseSchema,
} from "./zod-schemas";
Expand Down Expand Up @@ -76,18 +76,18 @@ export function deserializeReferralProgramEditionConfigSetArray(
}

/**
* Deserialize a {@link ReferralProgramEditionConfigSetResponse} object.
* Deserialize a {@link ReferralProgramEditionSummariesResponse} object.
*/
export function deserializeReferralProgramEditionConfigSetResponse(
maybeResponse: SerializedReferralProgramEditionConfigSetResponse,
export function deserializeReferralProgramEditionSummariesResponse(
maybeResponse: SerializedReferralProgramEditionSummariesResponse,
valueLabel?: string,
): ReferralProgramEditionConfigSetResponse {
const schema = makeReferralProgramEditionConfigSetResponseSchema(valueLabel);
): ReferralProgramEditionSummariesResponse {
const schema = makeReferralProgramEditionSummariesResponseSchema(valueLabel);
const parsed = schema.safeParse(maybeResponse);

if (parsed.error) {
throw new Error(
`Cannot deserialize ReferralProgramEditionConfigSetResponse:\n${prettifyError(parsed.error)}\n`,
`Cannot deserialize ReferralProgramEditionSummariesResponse:\n${prettifyError(parsed.error)}\n`,
);
}

Expand Down
Loading
Loading