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
8 changes: 7 additions & 1 deletion backend/src/db/migrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,15 @@ export async function runMigrations(db) {
const ran = [];

for (const file of files) {
const mod = file.endsWith('.sql')
const loaded = file.endsWith('.sql')
? await loadSqlMigration(file)
: await import(pathToFileURL(join(MIGRATIONS_DIR, file)).href);
// Some migrations export `{ migration: { name, description, up } }` instead of
// top-level `version`/`up`; their version is the NNN_ filename prefix.
const mod =
typeof loaded.version !== 'number' && loaded.migration
? { ...loaded.migration, version: Number.parseInt(file.slice(0, 3), 10) }
: loaded;

if (typeof mod.version !== 'number') {
throw new Error(`Migration ${file} must export a numeric "version"`);
Expand Down
18 changes: 18 additions & 0 deletions backend/src/db/migrations/049_influencer_referral_codes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const version = 49;
export const description = 'Add influencer_referral_codes table for partner referral links';

export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS influencer_referral_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
influencer_handle TEXT NOT NULL,
campaign_id INTEGER,
active INTEGER NOT NULL DEFAULT 1,
click_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_influencer_codes_handle ON influencer_referral_codes(influencer_handle);
`);
}
21 changes: 20 additions & 1 deletion backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ import { createIndexReadRoutes } from './routes/indexRead.js';
import { createSep10Routes, createRequireWalletAuth } from './routes/sep10.js';
import { createZkInputsRoutes } from './routes/zkInputs.js';
import { createSocialTaskRoutes } from './routes/socialTasks.js';
import { createInfluencerReferralRoutes } from './routes/influencerReferralCodes.js';
import {
createInfluencerReferralRepository,
createInfluencerReferralService,
} from './services/influencerReferralCodes.js';
import { createTwitterVerificationService } from './services/twitterVerificationService.js';
import {
createNotificationRoutes,
Expand Down Expand Up @@ -836,7 +841,7 @@ export async function createApp(options = {}) {

const pruningJob = createPruningJob({ dal });
const sessionTokenCleanupJob = createSessionTokenCleanupJob({ dal });
const campaignRewardNotificationJob = createCampaignRewardNotificationJob({ dal, emailService });
const campaignRewardNotificationJob = createCampaignRewardNotificationJob({ dal, emailService: /** @type {any} */ (options).emailService });

const jobRunner = createJobRunner({
handlers: {
Expand Down Expand Up @@ -3325,6 +3330,18 @@ export async function createApp(options = {}) {
});
});

// #1369 — influencer referral code generator (trivela.network/ref/<name>).
// Generation/listing require an API key; resolving a code is public, so this
// is registered before the prefix-level API-key mounts below.
const influencerReferralRouter = createInfluencerReferralRoutes({
service: createInfluencerReferralService({
repository: createInfluencerReferralRepository({ db: dal.db }),
baseUrl: process.env.REFERRAL_BASE_URL ?? process.env.FRONTEND_URL ?? 'https://trivela.network',
}),
requireApiKey,
});
app.use(prefix, rateLimiter, influencerReferralRouter);

// Org + RBAC member management routes (Issue #608)
// Registered BEFORE the app.use(prefix, requireApiKey, ...) mounts so that
// master-key-only routes (POST /orgs) are not intercepted by the API-key
Expand Down Expand Up @@ -3357,6 +3374,8 @@ export async function createApp(options = {}) {
prefix,
rateLimiter,
createIpfsPinRoutes({ getService: () => ipfsPinService, campaignRepository, guard }),
);

// #1258 — fraud flag review (master key)
app.use(
`${prefix}/admin/fraud`,
Expand Down
100 changes: 100 additions & 0 deletions backend/src/influencerReferralCodes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import Database from 'better-sqlite3';
import { createApp } from './index.js';
import { runMigrations } from './db/migrate.js';
import {
createInfluencerReferralRepository,
createInfluencerReferralService,
InfluencerCodeError,
normalizeHandle,
slugifyHandle,
} from './services/influencerReferralCodes.js';

async function makeService() {
const db = new Database(':memory:');
await runMigrations(db);
return createInfluencerReferralService({
repository: createInfluencerReferralRepository({ db }),
baseUrl: 'https://trivela.network/',
});
}

test('normalizeHandle and slugifyHandle', () => {
assert.equal(normalizeHandle('@Some_Name'), 'Some_Name');
assert.equal(normalizeHandle('bad handle'), null);
assert.equal(normalizeHandle(''), null);
assert.equal(normalizeHandle(42), null);
assert.equal(slugifyHandle('Some_Name'), 'some-name');
assert.equal(slugifyHandle('___'), '');
});

test('generate builds ref URL and de-duplicates codes', async () => {
const service = await makeService();
const first = service.generate({ handle: '@Some_Name' });
assert.equal(first.code, 'some-name');
assert.equal(first.referralUrl, 'https://trivela.network/ref/some-name');
assert.equal(first.active, true);
assert.equal(service.generate({ handle: 'some_name', campaignId: 3 }).code, 'some-name-2');
assert.equal(service.generate({ handle: 'Some_Name' }).code, 'some-name-3');
assert.equal(service.list().length, 3);
});

test('generate rejects invalid input', async () => {
const service = await makeService();
for (const input of [{ handle: 'no spaces' }, { handle: '___' }, { handle: 'ok', campaignId: 'x' }]) {
assert.throws(() => service.generate(input), (e) => e instanceof InfluencerCodeError && e.status === 400);
}
});

test('resolve counts clicks and hides inactive/unknown codes', async () => {
const service = await makeService();
service.generate({ handle: 'alice' });
assert.equal(service.resolve('alice').clickCount, 1);
assert.equal(service.resolve('alice').clickCount, 2);
assert.equal(service.resolve('Bad Code!'), null);
assert.equal(service.resolve('nobody'), null);
service.setActive('alice', false);
assert.equal(service.resolve('alice'), null);
assert.equal(service.list()[0].clickCount, 2);
assert.throws(() => service.setActive('nobody', true), (e) => e.status === 404);
});

test('HTTP: auth required to generate, public to resolve', async () => {
const app = await createApp({
dbPath: ':memory:',
apiKey: 'test-key',
rateLimit: { windowMs: 60_000, maxRequests: 1000 },
});
const server = app.listen(0);
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}/api/v1/referral-codes`;
try {
const headers = { 'Content-Type': 'application/json' };
const denied = await fetch(base, { method: 'POST', headers, body: JSON.stringify({ handle: 'bob' }) });
assert.equal(denied.status, 401);

const authed = { ...headers, 'x-api-key': 'test-key' };
const created = await fetch(base, { method: 'POST', headers: authed, body: JSON.stringify({ handle: '@bob' }) });
assert.equal(created.status, 201);
assert.equal((await created.json()).code, 'bob');

const bad = await fetch(base, { method: 'POST', headers: authed, body: JSON.stringify({ handle: 'a b' }) });
assert.equal(bad.status, 400);

const resolved = await fetch(`${base}/bob`);
assert.equal(resolved.status, 200);
assert.equal((await resolved.json()).influencerHandle, 'bob');
assert.equal((await fetch(`${base}/missing`)).status, 404);

const list = await (await fetch(base, { headers: authed })).json();
assert.equal(list.data[0].clickCount, 1);

const patched = await fetch(`${base}/bob`, { method: 'PATCH', headers: authed, body: JSON.stringify({ active: false }) });
assert.equal(patched.status, 200);
assert.equal((await fetch(`${base}/bob`)).status, 404);
} finally {
await new Promise((r) => server.close(r));
}
});
1 change: 1 addition & 0 deletions backend/src/lib/logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { log as logger } from '../middleware/logger.js';
68 changes: 68 additions & 0 deletions backend/src/routes/influencerReferralCodes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @ts-check
import { Router } from 'express';
import { InfluencerCodeError } from '../services/influencerReferralCodes.js';

/**
* Influencer referral code routes.
*
* POST /referral-codes (auth) { handle, campaignId? } -> 201 code + referralUrl
* GET /referral-codes (auth) list codes with click counts
* PATCH /referral-codes/:code (auth) { active: boolean }
* GET /referral-codes/:code (public) resolve a code for /ref/:code landing; counts a click
*
* @param {{
* service: ReturnType<import('../services/influencerReferralCodes.js').createInfluencerReferralService>,
* requireApiKey?: import('express').RequestHandler | import('express').RequestHandler[],
* }} deps
*/
export function createInfluencerReferralRoutes({ service, requireApiKey }) {
const router = Router();
const auth = requireApiKey ? (Array.isArray(requireApiKey) ? requireApiKey : [requireApiKey]) : [];

/** @param {any} err @param {import('express').Response} res @param {import('express').NextFunction} next */
const handleError = (err, res, next) => {
if (err instanceof InfluencerCodeError) {
return res.status(err.status).json({ error: err.message, code: err.code });
}
return next(err);
};

router.post('/referral-codes', ...auth, (req, res, next) => {
try {
const { handle, campaignId } = req.body ?? {};
return res.status(201).json(service.generate({ handle, campaignId }));
} catch (err) {
return handleError(err, res, next);
}
});

router.get('/referral-codes', ...auth, (_req, res) => {
res.json({ data: service.list() });
});

router.patch('/referral-codes/:code', ...auth, (req, res, next) => {
try {
if (typeof req.body?.active !== 'boolean') {
return res.status(400).json({ error: 'active must be a boolean', code: 'INVALID_INPUT' });
}
return res.json(service.setActive(req.params.code, req.body.active));
} catch (err) {
return handleError(err, res, next);
}
});

router.get('/referral-codes/:code', (req, res) => {
const record = service.resolve(req.params.code);
if (!record) {
return res.status(404).json({ error: 'referral code not found', code: 'CODE_NOT_FOUND' });
}
return res.json({
code: record.code,
influencerHandle: record.influencerHandle,
campaignId: record.campaignId,
referralUrl: record.referralUrl,
});
});

return router;
}
Loading