|
| 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