Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/src/dal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +105,7 @@ export async function createDal({
notificationPreferences: createSqliteNotificationPreferencesRepository({ db }),
sessionTokens: createSqliteSessionTokenRepository({ db }),
campaignRewardNotifications: createSqliteCampaignRewardNotificationRepository({ db }),
userBadges: createSqliteUserBadgeRepository({ db }),
db,
pgPool,
};
Expand Down
103 changes: 103 additions & 0 deletions backend/src/dal/sqliteUserBadgeRepository.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
185 changes: 185 additions & 0 deletions backend/src/dal/sqliteUserBadgeRepository.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 26 additions & 1 deletion backend/src/db/migrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Database>} db
Expand All @@ -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"`);
Expand Down
35 changes: 35 additions & 0 deletions backend/src/db/migrations/049_user_achievement_badges.js
Original file line number Diff line number Diff line change
@@ -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
);
`);
}
Loading