From 21d07d5e889125d23c69236b14c1af273613c298 Mon Sep 17 00:00:00 2001 From: brunomeno Date: Tue, 29 Sep 2026 17:22:47 +0100 Subject: [PATCH] feat(backend): user achievement badges calculator worker (#1247) Add a background worker that aggregates per-user milestone metrics from the indexer projection tables (participants, credit_events, claim_events, referral_credits) and grants global achievement badges. - services/achievementBadgeService: validated badge catalog plus pure evaluation and progress helpers - dal/sqliteUserBadgeRepository: keyset-paginated metric aggregation and a transactional, idempotent save (badges are never revoked) - jobs/achievementBadgesJob: batched run with per-user error isolation and a guard against a non-advancing cursor - migration 049: user_badges and user_milestone_progress tables - wired into the job runner hourly (BADGE_CALCULATOR_INTERVAL_MS, BADGE_CALCULATOR_BATCH_SIZE) Also teach the migration runner the `export const migration = {...}` shape used by 046_session_tokens, 047_campaign_tenant_isolation and 048_campaign_reward_notifications. Those files export no numeric `version`, so runMigrations threw at 046 and no later migration (including 049) could apply. Closes #1247 Co-Authored-By: Claude Opus 5.5 --- backend/.env.example | 4 + backend/src/dal/index.js | 2 + backend/src/dal/sqliteUserBadgeRepository.js | 103 ++++++++++ .../src/dal/sqliteUserBadgeRepository.test.js | 185 ++++++++++++++++++ backend/src/db/migrate.js | 27 ++- .../migrations/049_user_achievement_badges.js | 35 ++++ backend/src/index.js | 22 +++ backend/src/jobs/achievementBadgesJob.js | 94 +++++++++ backend/src/jobs/achievementBadgesJob.test.js | 183 +++++++++++++++++ .../src/services/achievementBadgeService.js | 185 ++++++++++++++++++ .../services/achievementBadgeService.test.js | 87 ++++++++ 11 files changed, 926 insertions(+), 1 deletion(-) create mode 100644 backend/src/dal/sqliteUserBadgeRepository.js create mode 100644 backend/src/dal/sqliteUserBadgeRepository.test.js create mode 100644 backend/src/db/migrations/049_user_achievement_badges.js create mode 100644 backend/src/jobs/achievementBadgesJob.js create mode 100644 backend/src/jobs/achievementBadgesJob.test.js create mode 100644 backend/src/services/achievementBadgeService.js create mode 100644 backend/src/services/achievementBadgeService.test.js diff --git a/backend/.env.example b/backend/.env.example index 96e235d3..ccef3934 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -101,6 +101,10 @@ AUTH_LOCKOUT_BASE_MS=60000 # JOB_BASE_DELAY_MS=1000 # Upper bound for retry backoff in milliseconds. # JOB_MAX_DELAY_MS=30000 +# Achievement badge calculator (#1247): how often to recompute milestone +# progress and grant badges, and how many users to aggregate per batch. +# BADGE_CALCULATOR_INTERVAL_MS=3600000 +# BADGE_CALCULATOR_BATCH_SIZE=500 # OpenTelemetry distributed tracing (#288) # Service identification in traces. OTEL_SERVICE_NAME=trivela-backend diff --git a/backend/src/dal/index.js b/backend/src/dal/index.js index cc72c9d1..77677878 100644 --- a/backend/src/dal/index.js +++ b/backend/src/dal/index.js @@ -26,6 +26,7 @@ import { createSqliteNotificationRepository } from './sqliteNotificationReposito import { createSqliteNotificationPreferencesRepository } from './sqliteNotificationPreferencesRepository.js'; import { createSqliteSessionTokenRepository } from './sqliteSessionTokenRepository.js'; import { createSqliteCampaignRewardNotificationRepository } from './sqliteCampaignRewardNotificationRepository.js'; +import { createSqliteUserBadgeRepository } from './sqliteUserBadgeRepository.js'; import { runPgMigrations } from './pg/migrate.js'; import { createPgCampaignRepository } from './pg/pgCampaignRepository.js'; @@ -104,6 +105,7 @@ export async function createDal({ notificationPreferences: createSqliteNotificationPreferencesRepository({ db }), sessionTokens: createSqliteSessionTokenRepository({ db }), campaignRewardNotifications: createSqliteCampaignRewardNotificationRepository({ db }), + userBadges: createSqliteUserBadgeRepository({ db }), db, pgPool, }; diff --git a/backend/src/dal/sqliteUserBadgeRepository.js b/backend/src/dal/sqliteUserBadgeRepository.js new file mode 100644 index 00000000..10e4feac --- /dev/null +++ b/backend/src/dal/sqliteUserBadgeRepository.js @@ -0,0 +1,103 @@ +/** + * Achievement badge storage and milestone aggregation (#1247). + * + * Metrics are aggregated from the indexer projection tables (migration 033). + * Users are paged by keyset (`user > afterUser ORDER BY user`) so the + * calculator holds at most one batch in memory regardless of user count; each + * branch of the user UNION filters on an indexed user column. + */ +export function createSqliteUserBadgeRepository({ db }) { + const stmtListUserMetrics = db.prepare(` + WITH batch AS ( + SELECT user FROM participants WHERE user > @afterUser + UNION + SELECT user FROM credit_events WHERE user > @afterUser + UNION + SELECT user FROM claim_events WHERE user > @afterUser + UNION + SELECT referrer AS user FROM referral_credits WHERE referrer > @afterUser + ORDER BY user + LIMIT @limit + ) + SELECT + b.user, + (SELECT COUNT(*) FROM participants p WHERE p.user = b.user) AS campaignsJoined, + (SELECT COUNT(*) FROM credit_events c WHERE c.user = b.user) AS creditsReceived, + (SELECT TOTAL(CAST(c.amount AS REAL)) FROM credit_events c WHERE c.user = b.user) AS pointsEarned, + (SELECT COUNT(*) FROM claim_events k WHERE k.user = b.user) AS claimsMade, + (SELECT COUNT(*) FROM referral_credits r WHERE r.referrer = b.user) AS referralsMade + FROM batch b + ORDER BY b.user + `); + + const stmtUpsertProgress = db.prepare(` + INSERT INTO user_milestone_progress + (user, campaigns_joined, credits_received, points_earned, claims_made, referrals_made, computed_at) + VALUES + (@user, @campaignsJoined, @creditsReceived, @pointsEarned, @claimsMade, @referralsMade, @computedAt) + ON CONFLICT(user) DO UPDATE SET + campaigns_joined = excluded.campaigns_joined, + credits_received = excluded.credits_received, + points_earned = excluded.points_earned, + claims_made = excluded.claims_made, + referrals_made = excluded.referrals_made, + computed_at = excluded.computed_at + `); + + const stmtAwardBadge = db.prepare(` + INSERT OR IGNORE INTO user_badges (user, badge_id, metric_value, awarded_at) + VALUES (?, ?, ?, ?) + `); + + const stmtListBadges = db.prepare(` + SELECT badge_id AS badgeId, metric_value AS metricValue, awarded_at AS awardedAt + FROM user_badges WHERE user = ? ORDER BY awarded_at, badge_id + `); + + const stmtGetProgress = db.prepare(` + SELECT + campaigns_joined AS campaignsJoined, + credits_received AS creditsReceived, + points_earned AS pointsEarned, + claims_made AS claimsMade, + referrals_made AS referralsMade, + computed_at AS computedAt + FROM user_milestone_progress WHERE user = ? + `); + + const saveTx = db.transaction(({ user, metrics, earned, computedAt }) => { + stmtUpsertProgress.run({ user, ...metrics, computedAt }); + let awarded = 0; + for (const { badgeId, metricValue } of earned) { + awarded += stmtAwardBadge.run(user, badgeId, metricValue, computedAt).changes; + } + return awarded; + }); + + return { + /** + * Next page of users (ordered by address) with their aggregate metrics. + * @param {{ afterUser?: string, limit: number }} opts + */ + listUserMetrics({ afterUser = '', limit }) { + return stmtListUserMetrics.all({ afterUser, limit }); + }, + + /** + * Atomically store the user's metric snapshot and grant any newly earned + * badges. Already-held badges are left untouched. + * @returns {number} badges newly granted + */ + saveResults({ user, metrics, earned, computedAt }) { + return saveTx({ user, metrics, earned, computedAt }); + }, + + listBadges(user) { + return stmtListBadges.all(user); + }, + + getProgress(user) { + return stmtGetProgress.get(user) ?? null; + }, + }; +} diff --git a/backend/src/dal/sqliteUserBadgeRepository.test.js b/backend/src/dal/sqliteUserBadgeRepository.test.js new file mode 100644 index 00000000..63f44f55 --- /dev/null +++ b/backend/src/dal/sqliteUserBadgeRepository.test.js @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import { createSqliteUserBadgeRepository } from './sqliteUserBadgeRepository.js'; +import { createAchievementBadgesJob } from '../jobs/achievementBadgesJob.js'; + +const silentLogger = { info() {}, warn() {}, error() {}, debug() {} }; + +async function setup() { + const db = new Database(':memory:'); + await runMigrations(db); + return { db, repository: createSqliteUserBadgeRepository({ db }) }; +} + +function seedProjections(db) { + const participant = db.prepare('INSERT INTO participants (user, campaign_id) VALUES (?, ?)'); + const credit = db.prepare('INSERT INTO credit_events (user, amount) VALUES (?, ?)'); + const claim = db.prepare('INSERT INTO claim_events (user, amount) VALUES (?, ?)'); + const referral = db.prepare('INSERT INTO referral_credits (referee, referrer) VALUES (?, ?)'); + + // GALICE: 5 campaigns, 1,200 points over two credits, one claim + for (let i = 1; i <= 5; i++) participant.run('GALICE', `c${i}`); + credit.run('GALICE', '700'); + credit.run('GALICE', '500'); + claim.run('GALICE', '300'); + + // GBOB: only appears as a referrer + referral.run('GREF1', 'GBOB'); + referral.run('GREF2', 'GBOB'); + + // GCAROL: one campaign, nothing else + participant.run('GCAROL', 'c1'); +} + +test('migrations create the badge tables', async () => { + const { db } = await setup(); + const tables = db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('user_badges', 'user_milestone_progress')", + ) + .all() + .map((r) => r.name) + .sort(); + assert.deepEqual(tables, ['user_badges', 'user_milestone_progress']); +}); + +test('listUserMetrics aggregates every projection table, in address order', async () => { + const { db, repository } = await setup(); + seedProjections(db); + + const rows = repository.listUserMetrics({ limit: 10 }); + + assert.deepEqual(rows, [ + { + user: 'GALICE', + campaignsJoined: 5, + creditsReceived: 2, + pointsEarned: 1200, + claimsMade: 1, + referralsMade: 0, + }, + { + user: 'GBOB', + campaignsJoined: 0, + creditsReceived: 0, + pointsEarned: 0, + claimsMade: 0, + referralsMade: 2, + }, + { + user: 'GCAROL', + campaignsJoined: 1, + creditsReceived: 0, + pointsEarned: 0, + claimsMade: 0, + referralsMade: 0, + }, + ]); +}); + +test('listUserMetrics pages by keyset cursor', async () => { + const { db, repository } = await setup(); + seedProjections(db); + + const first = repository.listUserMetrics({ limit: 2 }); + const second = repository.listUserMetrics({ afterUser: first[1].user, limit: 2 }); + + assert.deepEqual( + first.map((r) => r.user), + ['GALICE', 'GBOB'], + ); + assert.deepEqual( + second.map((r) => r.user), + ['GCAROL'], + ); + assert.deepEqual(repository.listUserMetrics({ afterUser: 'GCAROL', limit: 2 }), []); +}); + +test('saveResults upserts progress and never duplicates or revokes badges', async () => { + const { repository } = await setup(); + const metrics = { + campaignsJoined: 1, + creditsReceived: 0, + pointsEarned: 0, + claimsMade: 0, + referralsMade: 0, + }; + + const first = repository.saveResults({ + user: 'GA', + metrics, + earned: [{ badgeId: 'first_steps', metricValue: 1 }], + computedAt: '2026-09-29T00:00:00.000Z', + }); + const second = repository.saveResults({ + user: 'GA', + metrics: { ...metrics, campaignsJoined: 0 }, + earned: [], + computedAt: '2026-09-29T01:00:00.000Z', + }); + + assert.equal(first, 1); + assert.equal(second, 0); + assert.deepEqual(repository.listBadges('GA'), [ + { badgeId: 'first_steps', metricValue: 1, awardedAt: '2026-09-29T00:00:00.000Z' }, + ]); + assert.deepEqual(repository.getProgress('GA'), { + ...metrics, + campaignsJoined: 0, + computedAt: '2026-09-29T01:00:00.000Z', + }); + assert.equal(repository.getProgress('GNOBODY'), null); +}); + +test('saveResults rolls back the snapshot when a badge insert fails', async () => { + const { repository } = await setup(); + + assert.throws(() => + repository.saveResults({ + user: 'GA', + metrics: { + campaignsJoined: 1, + creditsReceived: 0, + pointsEarned: 0, + claimsMade: 0, + referralsMade: 0, + }, + // An object cannot be bound as a SQLite value, so the insert throws + // mid-transaction (OR IGNORE would swallow a plain constraint error). + earned: [{ badgeId: 'first_steps', metricValue: /** @type {any} */ ({}) }], + computedAt: '2026-09-29T00:00:00.000Z', + }), + ); + assert.equal(repository.getProgress('GA'), null); +}); + +test('job grants badges end-to-end from indexer projections', async () => { + const { db, repository } = await setup(); + seedProjections(db); + const job = createAchievementBadgesJob({ + repository, + batchSize: 2, + logger: silentLogger, + now: () => new Date('2026-09-29T12:00:00.000Z'), + }); + + const summary = await job(); + + assert.deepEqual(summary, { usersScanned: 3, badgesGranted: 6, failures: 0 }); + assert.deepEqual( + repository.listBadges('GALICE').map((b) => b.badgeId), + ['campaign_explorer', 'first_claim', 'first_steps', 'point_collector'], + ); + assert.deepEqual( + repository.listBadges('GBOB').map((b) => b.badgeId), + ['connector'], + ); + assert.deepEqual( + repository.listBadges('GCAROL').map((b) => b.badgeId), + ['first_steps'], + ); + + assert.equal((await job()).badgesGranted, 0); +}); diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 7993beed..edf10ac6 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -64,6 +64,28 @@ async function loadSqlMigration(file) { }; } +/** + * Some migrations export a single `migration = { name, description, up }` + * object instead of the documented `version` / `description` / `up` exports. + * Adapt them the same way as `.sql` files — version from the NNN_ prefix — so + * one off-convention file cannot halt every migration after it. + * + * @param {string} file + * @param {any} mod + */ +function normalizeModuleMigration(file, mod) { + if (typeof mod.version === 'number' || !mod.migration) return mod; + + const version = Number.parseInt(file.slice(0, 3), 10); + if (!Number.isInteger(version) || typeof mod.migration.up !== 'function') return mod; + + return { + version, + description: mod.migration.description ?? mod.migration.name ?? file, + up: mod.migration.up, + }; +} + /** * Run all pending migrations against the given database. * @param {InstanceType} db @@ -80,7 +102,10 @@ export async function runMigrations(db) { for (const file of files) { const mod = file.endsWith('.sql') ? await loadSqlMigration(file) - : await import(pathToFileURL(join(MIGRATIONS_DIR, file)).href); + : normalizeModuleMigration( + file, + await import(pathToFileURL(join(MIGRATIONS_DIR, file)).href), + ); if (typeof mod.version !== 'number') { throw new Error(`Migration ${file} must export a numeric "version"`); diff --git a/backend/src/db/migrations/049_user_achievement_badges.js b/backend/src/db/migrations/049_user_achievement_badges.js new file mode 100644 index 00000000..db50fc3e --- /dev/null +++ b/backend/src/db/migrations/049_user_achievement_badges.js @@ -0,0 +1,35 @@ +export const version = 49; +export const description = 'User achievement badges and milestone progress snapshots (#1247)'; + +/** + * Written by the achievement badge calculator (jobs/achievementBadgesJob.js), + * which aggregates the indexer projection tables from migration 033. + * + * - user_badges is append-only: a badge, once granted, is never revoked by the + * worker, so the (user, badge_id) primary key doubles as the idempotency key. + * - user_milestone_progress holds the latest metric snapshot per user; badge + * progress is derived from it at read time against the badge catalog, so a + * catalog change never requires a data migration. + */ +export function up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS user_badges ( + user TEXT NOT NULL, + badge_id TEXT NOT NULL, + metric_value REAL NOT NULL, + awarded_at TEXT NOT NULL, + PRIMARY KEY (user, badge_id) + ); + CREATE INDEX IF NOT EXISTS idx_user_badges_badge ON user_badges(badge_id); + + CREATE TABLE IF NOT EXISTS user_milestone_progress ( + user TEXT PRIMARY KEY, + campaigns_joined INTEGER NOT NULL DEFAULT 0, + credits_received INTEGER NOT NULL DEFAULT 0, + points_earned REAL NOT NULL DEFAULT 0, + claims_made INTEGER NOT NULL DEFAULT 0, + referrals_made INTEGER NOT NULL DEFAULT 0, + computed_at TEXT NOT NULL + ); + `); +} diff --git a/backend/src/index.js b/backend/src/index.js index ff484bba..053a410d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -131,6 +131,7 @@ import { createApiDocsRouter } from './routes/apiDocs.js'; import { createPruningJob } from './jobs/pruningJob.js'; import { createSessionTokenCleanupJob } from './jobs/sessionTokenCleanupJob.js'; import { createCampaignRewardNotificationJob } from './jobs/campaignRewardNotificationJob.js'; +import { createAchievementBadgesJob } from './jobs/achievementBadgesJob.js'; import { setupSentry, captureException } from './services/sentryIntegration.js'; import { purgePiiForUser, @@ -837,6 +838,11 @@ export async function createApp(options = {}) { const pruningJob = createPruningJob({ dal }); const sessionTokenCleanupJob = createSessionTokenCleanupJob({ dal }); const campaignRewardNotificationJob = createCampaignRewardNotificationJob({ dal, emailService }); + const achievementBadgesJob = createAchievementBadgesJob({ + repository: dal.userBadges, + batchSize: normalizePositiveInteger(process.env.BADGE_CALCULATOR_BATCH_SIZE, 500), + logger: log, + }); const jobRunner = createJobRunner({ handlers: { @@ -869,6 +875,9 @@ export async function createApp(options = {}) { async campaign_reward_notifications() { await campaignRewardNotificationJob(); }, + async achievement_badges() { + await achievementBadgesJob(); + }, }, logger: log, deadLetter: failedJobRepository, @@ -928,6 +937,19 @@ export async function createApp(options = {}) { ).unref?.(); } + // Hourly achievement badge calculation (#1247) + if (!options.disableJobs) { + const badgeCalculatorIntervalMs = normalizePositiveInteger( + process.env.BADGE_CALCULATOR_INTERVAL_MS, + 60 * 60 * 1000, // 1 hour + ); + jobRunner.enqueue('achievement_badges', null); + setInterval( + () => jobRunner.enqueue('achievement_badges', null), + badgeCalculatorIntervalMs, + ).unref?.(); + } + // #1255 — IPFS pinning of campaign assets (enabled when PINATA_JWT is set) const ipfsPinService = /** @type {any} */ (options.ipfsPinService) ?? diff --git a/backend/src/jobs/achievementBadgesJob.js b/backend/src/jobs/achievementBadgesJob.js new file mode 100644 index 00000000..34638fe2 --- /dev/null +++ b/backend/src/jobs/achievementBadgesJob.js @@ -0,0 +1,94 @@ +import { log } from '../middleware/logger.js'; +import { + BADGE_CATALOG, + evaluateBadges, + normalizeMetrics, + validateBadgeCatalog, +} from '../services/achievementBadgeService.js'; + +const DEFAULT_BATCH_SIZE = 500; +const MAX_BATCH_SIZE = 5_000; + +/** + * Background calculator for global achievement badges (#1247). + * + * Walks every user seen by the indexer in keyset-paged batches, recomputes + * their aggregate milestone metrics, stores the snapshot, and grants any badge + * whose threshold is now met. Runs are idempotent: re-running grants nothing + * new and only refreshes snapshots, so the job is safe to retry or overlap + * with a restart. One user's failure is logged and skipped rather than + * aborting the whole run. + * + * @param {{ + * repository: ReturnType | undefined, + * catalog?: readonly import('../services/achievementBadgeService.js').BadgeDefinition[], + * batchSize?: number, + * logger?: any, + * now?: () => Date, + * }} deps + */ +export function createAchievementBadgesJob({ + repository, + catalog = BADGE_CATALOG, + batchSize = DEFAULT_BATCH_SIZE, + logger = log, + now = () => new Date(), +}) { + validateBadgeCatalog(catalog); + const limit = Math.min( + Number.isInteger(batchSize) && batchSize > 0 ? batchSize : DEFAULT_BATCH_SIZE, + MAX_BATCH_SIZE, + ); + + return async function calculateAchievementBadges() { + if (!repository) { + logger.warn('[achievementBadges] User badge repository not available'); + return { usersScanned: 0, badgesGranted: 0, failures: 0 }; + } + + const computedAt = now().toISOString(); + let afterUser = ''; + let usersScanned = 0; + let badgesGranted = 0; + let failures = 0; + + for (;;) { + const rows = repository.listUserMetrics({ afterUser, limit }); + if (rows.length === 0) break; + + for (const row of rows) { + if (typeof row.user !== 'string' || row.user.length === 0) { + failures++; + continue; + } + try { + const metrics = normalizeMetrics(row); + const earned = evaluateBadges(metrics, catalog); + badgesGranted += repository.saveResults({ + user: row.user, + metrics, + earned, + computedAt, + }); + } catch (err) { + failures++; + logger.warn({ user: row.user, err }, '[achievementBadges] Failed to process user'); + } + } + + usersScanned += rows.length; + const lastUser = rows[rows.length - 1].user; + // Keyset cursor must strictly advance, otherwise a misbehaving query + // would spin this loop forever. + if (typeof lastUser !== 'string' || lastUser <= afterUser) { + throw new Error('[achievementBadges] Pagination cursor did not advance'); + } + afterUser = lastUser; + if (rows.length < limit) break; + } + + const summary = { usersScanned, badgesGranted, failures }; + logger.info(summary, '[achievementBadges] Calculation completed'); + return summary; + }; +} diff --git a/backend/src/jobs/achievementBadgesJob.test.js b/backend/src/jobs/achievementBadgesJob.test.js new file mode 100644 index 00000000..7288c4d2 --- /dev/null +++ b/backend/src/jobs/achievementBadgesJob.test.js @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAchievementBadgesJob } from './achievementBadgesJob.js'; + +const silentLogger = { info() {}, warn() {}, error() {}, debug() {} }; +const fixedNow = () => new Date('2026-09-29T12:00:00.000Z'); + +/** In-memory repository mirroring the SQLite repository's contract. */ +function createFakeRepository(users) { + const sorted = [...users].sort((a, b) => (a.user < b.user ? -1 : 1)); + const badges = new Map(); + const saved = []; + const pageCalls = []; + + return { + saved, + badges, + pageCalls, + listUserMetrics({ afterUser = '', limit }) { + pageCalls.push({ afterUser, limit }); + return sorted.filter((u) => u.user > afterUser).slice(0, limit); + }, + saveResults({ user, metrics, earned, computedAt }) { + saved.push({ user, metrics, computedAt }); + const held = badges.get(user) ?? new Set(); + let awarded = 0; + for (const { badgeId } of earned) { + if (!held.has(badgeId)) { + held.add(badgeId); + awarded++; + } + } + badges.set(user, held); + return awarded; + }, + }; +} + +test('grants badges and snapshots metrics for every user', async () => { + const repository = createFakeRepository([ + { user: 'GA', campaignsJoined: 1, claimsMade: 1 }, + { user: 'GB', campaignsJoined: 0 }, + ]); + const job = createAchievementBadgesJob({ repository, logger: silentLogger, now: fixedNow }); + + const summary = await job(); + + assert.deepEqual(summary, { usersScanned: 2, badgesGranted: 2, failures: 0 }); + assert.deepEqual([...repository.badges.get('GA')], ['first_steps', 'first_claim']); + assert.equal(repository.badges.get('GB').size, 0); + assert.equal(repository.saved[0].computedAt, '2026-09-29T12:00:00.000Z'); + assert.equal(repository.saved[1].metrics.campaignsJoined, 0); +}); + +test('re-running is idempotent', async () => { + const repository = createFakeRepository([{ user: 'GA', referralsMade: 12 }]); + const job = createAchievementBadgesJob({ repository, logger: silentLogger, now: fixedNow }); + + assert.equal((await job()).badgesGranted, 2); + assert.equal((await job()).badgesGranted, 0); +}); + +test('pages through users with a strictly advancing keyset cursor', async () => { + const users = ['G1', 'G2', 'G3', 'G4', 'G5'].map((user) => ({ user, claimsMade: 1 })); + const repository = createFakeRepository(users); + const job = createAchievementBadgesJob({ + repository, + batchSize: 2, + logger: silentLogger, + now: fixedNow, + }); + + const summary = await job(); + + assert.equal(summary.usersScanned, 5); + assert.deepEqual( + repository.pageCalls.map((c) => c.afterUser), + ['', 'G2', 'G4'], + ); + assert.ok(repository.pageCalls.every((c) => c.limit === 2)); +}); + +test('stops when the last page is exactly full', async () => { + const repository = createFakeRepository([{ user: 'G1' }, { user: 'G2' }]); + const job = createAchievementBadgesJob({ repository, batchSize: 2, logger: silentLogger }); + + await job(); + + assert.deepEqual( + repository.pageCalls.map((c) => c.afterUser), + ['', 'G2'], + ); +}); + +test('one failing user does not abort the run', async () => { + const repository = createFakeRepository([ + { user: 'GA', claimsMade: 1 }, + { user: 'GB', claimsMade: 1 }, + ]); + const save = repository.saveResults; + repository.saveResults = (args) => { + if (args.user === 'GA') throw new Error('disk full'); + return save(args); + }; + const warnings = []; + const logger = { ...silentLogger, warn: (...args) => warnings.push(args) }; + const job = createAchievementBadgesJob({ repository, logger, now: fixedNow }); + + const summary = await job(); + + assert.deepEqual(summary, { usersScanned: 2, badgesGranted: 1, failures: 1 }); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0].user, 'GA'); +}); + +test('rows without a valid user are counted as failures and skipped', async () => { + const repository = createFakeRepository([{ user: 'GA', claimsMade: 1 }]); + const list = repository.listUserMetrics; + let first = true; + repository.listUserMetrics = (opts) => { + if (!first) return list(opts); + first = false; + return [{ user: '', claimsMade: 1 }, ...list(opts)]; + }; + const job = createAchievementBadgesJob({ repository, logger: silentLogger, now: fixedNow }); + + const summary = await job(); + + assert.equal(summary.failures, 1); + assert.equal(summary.badgesGranted, 1); +}); + +test('throws instead of looping forever when a full page repeats', async () => { + const repository = createFakeRepository([]); + // Ignores the cursor: every call returns the same full page. + repository.listUserMetrics = () => [{ user: 'GA' }, { user: 'GB' }]; + const job = createAchievementBadgesJob({ repository, batchSize: 2, logger: silentLogger }); + + await assert.rejects(job(), /cursor did not advance/); +}); + +test('cursor regression on a later page is rejected', async () => { + let call = 0; + const repository = createFakeRepository([]); + repository.listUserMetrics = () => (call++ === 0 ? [{ user: 'GB' }] : [{ user: 'GA' }]); + const job = createAchievementBadgesJob({ repository, batchSize: 1, logger: silentLogger }); + + await assert.rejects(job(), /cursor did not advance/); +}); + +test('missing repository is a logged no-op', async () => { + const warnings = []; + const job = createAchievementBadgesJob({ + repository: undefined, + logger: { ...silentLogger, warn: (msg) => warnings.push(msg) }, + }); + + assert.deepEqual(await job(), { usersScanned: 0, badgesGranted: 0, failures: 0 }); + assert.equal(warnings.length, 1); +}); + +test('rejects an invalid catalog at construction time', () => { + assert.throws( + () => + createAchievementBadgesJob({ + repository: createFakeRepository([]), + catalog: [], + logger: silentLogger, + }), + /non-empty/, + ); +}); + +test('clamps an invalid or oversized batch size', async () => { + const repository = createFakeRepository([]); + await createAchievementBadgesJob({ repository, batchSize: -3, logger: silentLogger })(); + await createAchievementBadgesJob({ repository, batchSize: 1e9, logger: silentLogger })(); + + assert.deepEqual( + repository.pageCalls.map((c) => c.limit), + [500, 5_000], + ); +}); diff --git a/backend/src/services/achievementBadgeService.js b/backend/src/services/achievementBadgeService.js new file mode 100644 index 00000000..d03be751 --- /dev/null +++ b/backend/src/services/achievementBadgeService.js @@ -0,0 +1,185 @@ +/** + * User achievement badges (#1247). + * + * Global badges are granted from aggregate milestone metrics derived from the + * indexer projection tables (participants, credit_events, claim_events, + * referral_credits). Everything in this module is pure so the calculator's + * rules can be tested without a database. + * + * A badge is earned once its metric reaches the threshold. Badges are never + * revoked: a user who later deregisters from a campaign keeps what they earned. + */ + +/** + * @typedef {'campaignsJoined' | 'creditsReceived' | 'pointsEarned' | 'claimsMade' | 'referralsMade'} BadgeMetric + * @typedef {{ id: string, name: string, description: string, metric: BadgeMetric, threshold: number }} BadgeDefinition + * @typedef {Record} UserMetrics + */ + +/** @type {readonly BadgeMetric[]} */ +export const BADGE_METRICS = Object.freeze([ + 'campaignsJoined', + 'creditsReceived', + 'pointsEarned', + 'claimsMade', + 'referralsMade', +]); + +/** @type {readonly BadgeDefinition[]} */ +export const BADGE_CATALOG = Object.freeze( + [ + { + id: 'first_steps', + name: 'First Steps', + description: 'Joined your first campaign', + metric: 'campaignsJoined', + threshold: 1, + }, + { + id: 'campaign_explorer', + name: 'Campaign Explorer', + description: 'Joined 5 campaigns', + metric: 'campaignsJoined', + threshold: 5, + }, + { + id: 'campaign_veteran', + name: 'Campaign Veteran', + description: 'Joined 25 campaigns', + metric: 'campaignsJoined', + threshold: 25, + }, + { + id: 'point_collector', + name: 'Point Collector', + description: 'Earned 1,000 points', + metric: 'pointsEarned', + threshold: 1_000, + }, + { + id: 'point_magnate', + name: 'Point Magnate', + description: 'Earned 10,000 points', + metric: 'pointsEarned', + threshold: 10_000, + }, + { + id: 'first_claim', + name: 'First Claim', + description: 'Claimed rewards for the first time', + metric: 'claimsMade', + threshold: 1, + }, + { + id: 'steady_claimer', + name: 'Steady Claimer', + description: 'Claimed rewards 10 times', + metric: 'claimsMade', + threshold: 10, + }, + { + id: 'connector', + name: 'Connector', + description: 'Referred your first user', + metric: 'referralsMade', + threshold: 1, + }, + { + id: 'ambassador', + name: 'Ambassador', + description: 'Referred 10 users', + metric: 'referralsMade', + threshold: 10, + }, + ].map((badge) => Object.freeze(badge)), +); + +const BADGE_ID_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + +/** + * Throws on a malformed catalog so a bad definition fails at startup rather + * than silently granting (or never granting) a badge. + * + * @param {readonly BadgeDefinition[]} catalog + * @returns {readonly BadgeDefinition[]} + */ +export function validateBadgeCatalog(catalog) { + if (!Array.isArray(catalog) || catalog.length === 0) { + throw new TypeError('Badge catalog must be a non-empty array'); + } + + const seen = new Set(); + for (const badge of catalog) { + if (!badge || typeof badge !== 'object') { + throw new TypeError('Badge definition must be an object'); + } + if (typeof badge.id !== 'string' || !BADGE_ID_PATTERN.test(badge.id)) { + throw new TypeError(`Invalid badge id: ${JSON.stringify(badge.id)}`); + } + if (seen.has(badge.id)) { + throw new TypeError(`Duplicate badge id: ${badge.id}`); + } + seen.add(badge.id); + if (!BADGE_METRICS.includes(badge.metric)) { + throw new TypeError(`Badge ${badge.id} has unknown metric: ${JSON.stringify(badge.metric)}`); + } + if (!Number.isFinite(badge.threshold) || badge.threshold <= 0) { + throw new TypeError(`Badge ${badge.id} threshold must be a positive finite number`); + } + } + + return catalog; +} + +/** + * Coerce raw aggregate values into non-negative finite numbers. Missing, + * negative, or non-numeric values count as 0 so corrupt rows can never earn a + * badge. + * + * @param {Partial> | null | undefined} raw + * @returns {UserMetrics} + */ +export function normalizeMetrics(raw) { + /** @type {any} */ + const metrics = {}; + for (const metric of BADGE_METRICS) { + const value = Number(raw?.[metric]); + metrics[metric] = Number.isFinite(value) && value > 0 ? value : 0; + } + return metrics; +} + +/** + * Badges whose threshold the metrics meet. + * + * @param {Partial>} rawMetrics + * @param {readonly BadgeDefinition[]} [catalog] + * @returns {{ badgeId: string, metricValue: number }[]} + */ +export function evaluateBadges(rawMetrics, catalog = BADGE_CATALOG) { + const metrics = normalizeMetrics(rawMetrics); + return catalog + .filter((badge) => metrics[badge.metric] >= badge.threshold) + .map((badge) => ({ badgeId: badge.id, metricValue: metrics[badge.metric] })); +} + +/** + * Per-badge progress toward each threshold, for display. + * + * @param {Partial>} rawMetrics + * @param {readonly BadgeDefinition[]} [catalog] + * @returns {{ badgeId: string, current: number, threshold: number, ratio: number, earned: boolean }[]} + */ +export function badgeProgress(rawMetrics, catalog = BADGE_CATALOG) { + const metrics = normalizeMetrics(rawMetrics); + return catalog.map((badge) => { + const current = metrics[badge.metric]; + return { + badgeId: badge.id, + current, + threshold: badge.threshold, + ratio: Math.min(1, current / badge.threshold), + earned: current >= badge.threshold, + }; + }); +} diff --git a/backend/src/services/achievementBadgeService.test.js b/backend/src/services/achievementBadgeService.test.js new file mode 100644 index 00000000..c5ea523b --- /dev/null +++ b/backend/src/services/achievementBadgeService.test.js @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BADGE_CATALOG, + badgeProgress, + evaluateBadges, + normalizeMetrics, + validateBadgeCatalog, +} from './achievementBadgeService.js'; + +const badge = (overrides = {}) => ({ + id: 'test_badge', + name: 'Test', + description: 'Test badge', + metric: 'claimsMade', + threshold: 1, + ...overrides, +}); + +test('built-in catalog is valid and frozen', () => { + assert.equal(validateBadgeCatalog(BADGE_CATALOG), BADGE_CATALOG); + assert.ok(Object.isFrozen(BADGE_CATALOG)); + assert.ok(BADGE_CATALOG.every((b) => Object.isFrozen(b))); +}); + +test('validateBadgeCatalog rejects malformed catalogs', () => { + assert.throws(() => validateBadgeCatalog([]), /non-empty/); + assert.throws(() => validateBadgeCatalog(/** @type {any} */ (null)), /non-empty/); + assert.throws(() => validateBadgeCatalog([/** @type {any} */ (null)]), /must be an object/); + assert.throws(() => validateBadgeCatalog([badge({ id: 'Bad-Id' })]), /Invalid badge id/); + assert.throws(() => validateBadgeCatalog([badge(), badge()]), /Duplicate badge id/); + assert.throws( + () => validateBadgeCatalog([badge({ metric: /** @type {any} */ ('logins') })]), + /unknown metric/, + ); + for (const threshold of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => validateBadgeCatalog([badge({ threshold })]), /positive finite/); + } +}); + +test('normalizeMetrics coerces missing, negative, and non-numeric values to 0', () => { + assert.deepEqual(normalizeMetrics(undefined), { + campaignsJoined: 0, + creditsReceived: 0, + pointsEarned: 0, + claimsMade: 0, + referralsMade: 0, + }); + assert.deepEqual( + normalizeMetrics({ + campaignsJoined: '3', + creditsReceived: -4, + pointsEarned: 'abc', + claimsMade: Number.POSITIVE_INFINITY, + referralsMade: 2, + }), + { campaignsJoined: 3, creditsReceived: 0, pointsEarned: 0, claimsMade: 0, referralsMade: 2 }, + ); +}); + +test('evaluateBadges grants exactly the badges at or above threshold', () => { + assert.deepEqual(evaluateBadges({}), []); + + const earned = evaluateBadges({ campaignsJoined: 5, pointsEarned: 999, referralsMade: 1 }); + assert.deepEqual(earned, [ + { badgeId: 'first_steps', metricValue: 5 }, + { badgeId: 'campaign_explorer', metricValue: 5 }, + { badgeId: 'connector', metricValue: 1 }, + ]); +}); + +test('evaluateBadges honours a custom catalog', () => { + const catalog = [badge({ id: 'ten_claims', threshold: 10 })]; + assert.deepEqual(evaluateBadges({ claimsMade: 9 }, catalog), []); + assert.deepEqual(evaluateBadges({ claimsMade: 10 }, catalog), [ + { badgeId: 'ten_claims', metricValue: 10 }, + ]); +}); + +test('badgeProgress reports a capped ratio for every badge', () => { + const catalog = [badge({ id: 'a', threshold: 4 }), badge({ id: 'b', threshold: 2 })]; + assert.deepEqual(badgeProgress({ claimsMade: 3 }, catalog), [ + { badgeId: 'a', current: 3, threshold: 4, ratio: 0.75, earned: false }, + { badgeId: 'b', current: 3, threshold: 2, ratio: 1, earned: true }, + ]); + assert.equal(badgeProgress({}).length, BADGE_CATALOG.length); +});