Skip to content

Commit c536cf9

Browse files
committed
feat(backend): add contributor badge and achievement system (#788)
1 parent 39b844c commit c536cf9

13 files changed

Lines changed: 929 additions & 22 deletions

File tree

‎backend/data/badges.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]

‎backend/src/app.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from './services/bountyStore';
3535

3636
import { listOpenIssues } from './services/openIssues';
37+
import { getContributorBadges } from './services/badgeService';
3738

3839
import {
3940
bountyIdSchema,
@@ -867,6 +868,22 @@ app.get('/api/maintainers/:maintainer/metrics', (req: Request, res: Response) =>
867868
}
868869
});
869870

871+
app.get('/api/contributors/:address/badges', (req: Request, res: Response) => {
872+
try {
873+
const { address } = req.params;
874+
875+
if (!address || typeof address !== 'string' || !isValidStellarAddress(address)) {
876+
jsonError(res, req, 400, 'Valid contributor address is required.');
877+
return;
878+
}
879+
880+
const badges = getContributorBadges(address);
881+
res.json({ data: badges });
882+
} catch (error) {
883+
sendError(res, req, error);
884+
}
885+
});
886+
870887
app.get('/api/metrics', async (_req: Request, res: Response) => {
871888
try {
872889
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');

‎backend/src/docs/openapi.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,11 @@ registry.registerPath({
128128
}),
129129
deadlineBefore: z.string().optional().openapi({
130130
description: "Filter bounties with deadline before this ISO 8601 date string.",
131-
example: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
131+
example: "2026-09-27T00:00:00.000Z",
132132
}),
133133
deadlineAfter: z.string().optional().openapi({
134134
description: "Filter bounties with deadline after this ISO 8601 date string.",
135-
example: new Date().toISOString(),
135+
example: "2026-08-28T00:00:00.000Z",
136136
}),
137137
page: z.number().int().min(1).optional().openapi({
138138
description: "Page number (starts at 1, default 1).",
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { logStructured } from "../logger";
4+
import { sendNotification, type NotificationRecipient } from "./notificationService";
5+
import { listBounties, type BountyRecord } from "./bountyStore";
6+
7+
/**
8+
* Metadata definition for a badge/achievement.
9+
*/
10+
export interface BadgeDefinition {
11+
/** Unique badge identifier */
12+
id: string;
13+
/** Display name of the badge */
14+
name: string;
15+
/** Description of what this badge represents */
16+
description: string;
17+
/** Category of the badge */
18+
category: "completion" | "milestone" | "ranking" | "special";
19+
/** Icon/emoji for the badge */
20+
icon: string;
21+
}
22+
23+
/**
24+
* Standard badge criteria definitions.
25+
*/
26+
export const BADGE_DEFINITIONS: Record<string, BadgeDefinition> = {
27+
"first-bounty-completed": {
28+
id: "first-bounty-completed",
29+
name: "First Bounty Completed",
30+
description: "Awarded upon successfully completing and receiving payout for your first bounty.",
31+
category: "completion",
32+
icon: "🎯",
33+
},
34+
"ten-bounties-completed": {
35+
id: "ten-bounties-completed",
36+
name: "10 Bounties Completed",
37+
description: "Awarded upon reaching the milestone of 10 successfully completed bounties.",
38+
category: "milestone",
39+
icon: "🏆",
40+
},
41+
"top-earner-of-month": {
42+
id: "top-earner-of-month",
43+
name: "Top Earner of the Month",
44+
description: "Awarded to the contributor with the highest earnings across all released bounties in a calendar month.",
45+
category: "ranking",
46+
icon: "⭐",
47+
},
48+
};
49+
50+
/**
51+
* A badge earned by a contributor.
52+
*/
53+
export interface BadgeRecord {
54+
/** Unique ID of the badge award instance */
55+
id: string;
56+
/** Badge criteria ID (e.g. "first-bounty-completed") */
57+
badgeId: string;
58+
/** Display name of the badge */
59+
name: string;
60+
/** Badge description */
61+
description: string;
62+
/** Badge category */
63+
category?: string;
64+
/** Badge icon */
65+
icon?: string;
66+
/** Stellar address of the contributor who earned the badge */
67+
contributor: string;
68+
/** Unix timestamp in seconds when the badge was awarded */
69+
awardedAt: number;
70+
/** Additional metadata (e.g. bountyId, month, count, amount) */
71+
metadata?: Record<string, unknown>;
72+
}
73+
74+
function nowInSeconds(): number {
75+
return Math.floor(Date.now() / 1000);
76+
}
77+
78+
function getBadgesStorePath(): string {
79+
if (process.env.BADGES_STORE_PATH?.trim()) {
80+
return path.resolve(process.env.BADGES_STORE_PATH.trim());
81+
}
82+
83+
if (process.env.BOUNTY_STORE_PATH?.trim()) {
84+
const base = path.resolve(process.env.BOUNTY_STORE_PATH.trim());
85+
return base.endsWith(".json")
86+
? base.replace(/\.json$/i, ".badges.json")
87+
: `${base}.badges.json`;
88+
}
89+
90+
return path.resolve(__dirname, "../../data/badges.json");
91+
}
92+
93+
function ensureBadgesStore(): void {
94+
const storePath = getBadgesStorePath();
95+
fs.mkdirSync(path.dirname(storePath), { recursive: true });
96+
97+
if (!fs.existsSync(storePath)) {
98+
fs.writeFileSync(storePath, JSON.stringify([], null, 2));
99+
return;
100+
}
101+
102+
const raw = fs.readFileSync(storePath, "utf8").trim();
103+
if (!raw) {
104+
fs.writeFileSync(storePath, JSON.stringify([], null, 2));
105+
}
106+
}
107+
108+
export function readBadgesStore(): BadgeRecord[] {
109+
ensureBadgesStore();
110+
const storePath = getBadgesStorePath();
111+
try {
112+
return JSON.parse(fs.readFileSync(storePath, "utf8")) as BadgeRecord[];
113+
} catch {
114+
return [];
115+
}
116+
}
117+
118+
export function writeBadgesStore(records: BadgeRecord[]): void {
119+
ensureBadgesStore();
120+
fs.writeFileSync(getBadgesStorePath(), JSON.stringify(records, null, 2));
121+
}
122+
123+
function nextBadgeId(records: BadgeRecord[]): string {
124+
const highest = records.reduce((max, record) => {
125+
const numeric = Number(record.id.replace("BDG-", ""));
126+
return Number.isFinite(numeric) ? Math.max(max, numeric) : max;
127+
}, 0);
128+
return `BDG-${String(highest + 1).padStart(6, "0")}`;
129+
}
130+
131+
/**
132+
* Format a unix timestamp in seconds to YYYY-MM month string.
133+
*/
134+
function toMonthKey(timestampInSeconds: number): string {
135+
const date = new Date(timestampInSeconds * 1000);
136+
const year = date.getUTCFullYear();
137+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
138+
return `${year}-${month}`;
139+
}
140+
141+
/**
142+
* Evaluates and awards any eligible badges for a contributor.
143+
* Synchronous storage evaluation.
144+
*
145+
* @param contributor - Stellar address of the contributor
146+
* @param triggerBounty - Optional bounty that triggered this evaluation
147+
* @returns Array of newly awarded badges
148+
*/
149+
export function evaluateContributorBadgesSync(
150+
contributor: string,
151+
triggerBounty?: BountyRecord,
152+
): BadgeRecord[] {
153+
const allBounties = listBounties();
154+
const allBadges = readBadgesStore();
155+
const contributorBadges = allBadges.filter((b) => b.contributor === contributor);
156+
157+
// Get all released bounties for this contributor, sorted chronologically
158+
const releasedForContributor = allBounties
159+
.filter((b) => b.status === "released" && b.contributor === contributor)
160+
.sort((a, b) => (a.releasedAt ?? a.createdAt) - (b.releasedAt ?? b.createdAt));
161+
162+
const newlyAwarded: BadgeRecord[] = [];
163+
const currentBadges = [...allBadges];
164+
165+
// 1. First Bounty Completed
166+
if (releasedForContributor.length >= 1) {
167+
const hasFirstBadge = contributorBadges.some(
168+
(b) => b.badgeId === "first-bounty-completed",
169+
);
170+
if (!hasFirstBadge) {
171+
const firstBounty = releasedForContributor[0];
172+
const awardedAt = firstBounty.releasedAt ?? nowInSeconds();
173+
const badgeDef = BADGE_DEFINITIONS["first-bounty-completed"];
174+
const newBadge: BadgeRecord = {
175+
id: nextBadgeId(currentBadges),
176+
badgeId: badgeDef.id,
177+
name: badgeDef.name,
178+
description: badgeDef.description,
179+
category: badgeDef.category,
180+
icon: badgeDef.icon,
181+
contributor,
182+
awardedAt,
183+
metadata: {
184+
bountyId: firstBounty.id,
185+
amount: firstBounty.amount,
186+
tokenSymbol: firstBounty.tokenSymbol,
187+
},
188+
};
189+
currentBadges.push(newBadge);
190+
newlyAwarded.push(newBadge);
191+
}
192+
}
193+
194+
// 2. 10 Bounties Completed
195+
if (releasedForContributor.length >= 10) {
196+
const hasTenBadge = contributorBadges.some(
197+
(b) => b.badgeId === "ten-bounties-completed",
198+
);
199+
if (!hasTenBadge) {
200+
const tenthBounty = releasedForContributor[9];
201+
const awardedAt = tenthBounty.releasedAt ?? nowInSeconds();
202+
const badgeDef = BADGE_DEFINITIONS["ten-bounties-completed"];
203+
const newBadge: BadgeRecord = {
204+
id: nextBadgeId(currentBadges),
205+
badgeId: badgeDef.id,
206+
name: badgeDef.name,
207+
description: badgeDef.description,
208+
category: badgeDef.category,
209+
icon: badgeDef.icon,
210+
contributor,
211+
awardedAt,
212+
metadata: {
213+
bountyId: tenthBounty.id,
214+
completedCount: releasedForContributor.length,
215+
},
216+
};
217+
currentBadges.push(newBadge);
218+
newlyAwarded.push(newBadge);
219+
}
220+
}
221+
222+
// 3. Top Earner of the Month
223+
// Group all released bounties across the platform by calendar month
224+
const allReleased = allBounties.filter((b) => b.status === "released" && b.contributor);
225+
const monthToContributorEarnings: Record<string, Record<string, number>> = {};
226+
const monthToLatestTimestamp: Record<string, number> = {};
227+
228+
for (const b of allReleased) {
229+
const ts = b.releasedAt ?? b.createdAt;
230+
const monthKey = toMonthKey(ts);
231+
if (!monthToContributorEarnings[monthKey]) {
232+
monthToContributorEarnings[monthKey] = {};
233+
}
234+
const c = b.contributor as string;
235+
monthToContributorEarnings[monthKey][c] =
236+
(monthToContributorEarnings[monthKey][c] ?? 0) + b.amount;
237+
monthToLatestTimestamp[monthKey] = Math.max(
238+
monthToLatestTimestamp[monthKey] ?? 0,
239+
ts,
240+
);
241+
}
242+
243+
for (const [monthKey, contributorEarnings] of Object.entries(monthToContributorEarnings)) {
244+
let topEarner: string | null = null;
245+
let maxEarnings = 0;
246+
247+
for (const [cAddress, totalEarned] of Object.entries(contributorEarnings)) {
248+
if (totalEarned > maxEarnings) {
249+
maxEarnings = totalEarned;
250+
topEarner = cAddress;
251+
}
252+
}
253+
254+
if (topEarner === contributor && maxEarnings > 0) {
255+
const hasMonthBadge = currentBadges.some(
256+
(b) =>
257+
b.contributor === contributor &&
258+
b.badgeId === "top-earner-of-month" &&
259+
(b.metadata?.month === monthKey || (!b.metadata?.month && monthKey === toMonthKey(b.awardedAt))),
260+
);
261+
262+
if (!hasMonthBadge) {
263+
const badgeDef = BADGE_DEFINITIONS["top-earner-of-month"];
264+
const awardedAt = monthToLatestTimestamp[monthKey] ?? nowInSeconds();
265+
const newBadge: BadgeRecord = {
266+
id: nextBadgeId(currentBadges),
267+
badgeId: badgeDef.id,
268+
name: badgeDef.name,
269+
description: `Top earner across all bounties in ${monthKey} with ${maxEarnings} XLM/tokens earned.`,
270+
category: badgeDef.category,
271+
icon: badgeDef.icon,
272+
contributor,
273+
awardedAt,
274+
metadata: {
275+
month: monthKey,
276+
totalEarnings: maxEarnings,
277+
triggerBountyId: triggerBounty?.id,
278+
},
279+
};
280+
currentBadges.push(newBadge);
281+
newlyAwarded.push(newBadge);
282+
}
283+
}
284+
}
285+
286+
if (newlyAwarded.length > 0) {
287+
writeBadgesStore(currentBadges);
288+
}
289+
290+
return newlyAwarded;
291+
}
292+
293+
/**
294+
* Evaluates and awards badges for a contributor, and triggers notifications for any newly earned badges.
295+
*
296+
* @param contributor - Stellar address of the contributor
297+
* @param triggerBounty - Optional bounty that triggered this evaluation
298+
* @returns Array of newly awarded badges
299+
*/
300+
export async function evaluateContributorBadges(
301+
contributor: string,
302+
triggerBounty?: BountyRecord,
303+
): Promise<BadgeRecord[]> {
304+
const newlyAwarded = evaluateContributorBadgesSync(contributor, triggerBounty);
305+
306+
// Dispatch notifications and logs for each newly awarded badge
307+
for (const badge of newlyAwarded) {
308+
const recipients: NotificationRecipient[] = [
309+
{ role: "contributor", address: contributor },
310+
];
311+
312+
sendNotification(recipients, "badge_earned", {
313+
badgeId: badge.badgeId,
314+
badgeName: badge.name,
315+
badgeDescription: badge.description,
316+
awardedAt: badge.awardedAt,
317+
contributor,
318+
metadata: badge.metadata,
319+
}).catch((err) =>
320+
logStructured("warn", "notification_failed", {
321+
operation: "evaluateContributorBadges",
322+
badgeId: badge.badgeId,
323+
contributor,
324+
message: err instanceof Error ? err.message : String(err),
325+
}),
326+
);
327+
328+
logStructured("info", "badge_awarded", {
329+
contributor,
330+
badgeId: badge.badgeId,
331+
badgeName: badge.name,
332+
awardedAt: badge.awardedAt,
333+
});
334+
}
335+
336+
return newlyAwarded;
337+
}
338+
339+
/**
340+
* Returns all earned badges for a contributor in chronological order.
341+
* Automatically evaluates eligibility on read.
342+
*
343+
* @param contributor - Stellar address of the contributor
344+
* @returns Array of earned BadgeRecords
345+
*/
346+
export function getContributorBadges(contributor: string): BadgeRecord[] {
347+
evaluateContributorBadgesSync(contributor);
348+
return readBadgesStore()
349+
.filter((b) => b.contributor === contributor)
350+
.sort((a, b) => a.awardedAt - b.awardedAt);
351+
}

0 commit comments

Comments
 (0)