From db5008ec576159aa9da25ead1b3efa49c6e231ab Mon Sep 17 00:00:00 2001
From: Framma Williansyah Akbar
Date: Wed, 30 Sep 2026 11:36:12 +0700
Subject: [PATCH] feat(growth): Automated referral code generator for partner
Twitter influencers (#1369)
---
backend/src/db/migrate.js | 8 +-
.../049_influencer_referral_codes.js | 18 ++
backend/src/index.js | 21 ++-
backend/src/influencerReferralCodes.test.js | 100 ++++++++++
backend/src/lib/logger.js | 1 +
backend/src/routes/influencerReferralCodes.js | 68 +++++++
.../src/services/influencerReferralCodes.js | 178 ++++++++++++++++++
frontend/src/App.jsx | 2 +
.../src/pages/InfluencerReferralRedirect.jsx | 50 +++++
.../pages/InfluencerReferralRedirect.test.jsx | 46 +++++
10 files changed, 490 insertions(+), 2 deletions(-)
create mode 100644 backend/src/db/migrations/049_influencer_referral_codes.js
create mode 100644 backend/src/influencerReferralCodes.test.js
create mode 100644 backend/src/lib/logger.js
create mode 100644 backend/src/routes/influencerReferralCodes.js
create mode 100644 backend/src/services/influencerReferralCodes.js
create mode 100644 frontend/src/pages/InfluencerReferralRedirect.jsx
create mode 100644 frontend/src/pages/InfluencerReferralRedirect.test.jsx
diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js
index 7993beed..e4532513 100644
--- a/backend/src/db/migrate.js
+++ b/backend/src/db/migrate.js
@@ -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"`);
diff --git a/backend/src/db/migrations/049_influencer_referral_codes.js b/backend/src/db/migrations/049_influencer_referral_codes.js
new file mode 100644
index 00000000..92f4c1d3
--- /dev/null
+++ b/backend/src/db/migrations/049_influencer_referral_codes.js
@@ -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);
+ `);
+}
diff --git a/backend/src/index.js b/backend/src/index.js
index ff484bba..14dc7e6a 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -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,
@@ -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: {
@@ -3325,6 +3330,18 @@ export async function createApp(options = {}) {
});
});
+ // #1369 — influencer referral code generator (trivela.network/ref/).
+ // 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
@@ -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`,
diff --git a/backend/src/influencerReferralCodes.test.js b/backend/src/influencerReferralCodes.test.js
new file mode 100644
index 00000000..6992725f
--- /dev/null
+++ b/backend/src/influencerReferralCodes.test.js
@@ -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));
+ }
+});
diff --git a/backend/src/lib/logger.js b/backend/src/lib/logger.js
new file mode 100644
index 00000000..9df606ca
--- /dev/null
+++ b/backend/src/lib/logger.js
@@ -0,0 +1 @@
+export { log as logger } from '../middleware/logger.js';
diff --git a/backend/src/routes/influencerReferralCodes.js b/backend/src/routes/influencerReferralCodes.js
new file mode 100644
index 00000000..56065a43
--- /dev/null
+++ b/backend/src/routes/influencerReferralCodes.js
@@ -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,
+ * 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;
+}
diff --git a/backend/src/services/influencerReferralCodes.js b/backend/src/services/influencerReferralCodes.js
new file mode 100644
index 00000000..78fa00c6
--- /dev/null
+++ b/backend/src/services/influencerReferralCodes.js
@@ -0,0 +1,178 @@
+// @ts-check
+
+const MAX_SLUG_LENGTH = 40;
+const HANDLE_PATTERN = /^[A-Za-z0-9_]{1,50}$/;
+const CODE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+/**
+ * Normalize a Twitter/X handle ("@Some_Name") to its bare form, or null if invalid.
+ * @param {unknown} handle
+ * @returns {string|null}
+ */
+export function normalizeHandle(handle) {
+ if (typeof handle !== 'string') return null;
+ const bare = handle.trim().replace(/^@/, '');
+ return HANDLE_PATTERN.test(bare) ? bare : null;
+}
+
+/**
+ * URL-safe slug for a handle: lowercase, underscores to hyphens, trimmed.
+ * @param {string} handle bare handle
+ * @returns {string}
+ */
+export function slugifyHandle(handle) {
+ return handle
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, MAX_SLUG_LENGTH)
+ .replace(/-+$/g, '');
+}
+
+/**
+ * @param {unknown} code
+ * @returns {boolean}
+ */
+export function isValidCode(code) {
+ return typeof code === 'string' && code.length <= 60 && CODE_PATTERN.test(code);
+}
+
+/**
+ * @param {{ db: InstanceType }} opts
+ */
+export function createInfluencerReferralRepository({ db }) {
+ const toRecord = (/** @type {any} */ row) =>
+ row && {
+ id: String(row.id),
+ code: row.code,
+ influencerHandle: row.influencer_handle,
+ campaignId: row.campaign_id == null ? null : String(row.campaign_id),
+ active: row.active === 1,
+ clickCount: row.click_count,
+ createdAt: row.created_at,
+ };
+
+ function getByCode(/** @type {string} */ code) {
+ return toRecord(
+ db.prepare('SELECT * FROM influencer_referral_codes WHERE code = ?').get(code),
+ );
+ }
+
+ function insert(/** @type {string} */ code, /** @type {string} */ handle, campaignId) {
+ const info = db
+ .prepare(
+ `INSERT OR IGNORE INTO influencer_referral_codes
+ (code, influencer_handle, campaign_id, created_at) VALUES (?, ?, ?, ?)`,
+ )
+ .run(code, handle, campaignId == null ? null : Number(campaignId), new Date().toISOString());
+ return info.changes === 0 ? null : getByCode(code);
+ }
+
+ function listAll() {
+ return db
+ .prepare('SELECT * FROM influencer_referral_codes ORDER BY created_at ASC, id ASC')
+ .all()
+ .map(toRecord);
+ }
+
+ function setActive(/** @type {string} */ code, /** @type {boolean} */ active) {
+ db.prepare('UPDATE influencer_referral_codes SET active = ? WHERE code = ?').run(
+ active ? 1 : 0,
+ code,
+ );
+ return getByCode(code);
+ }
+
+ function incrementClicks(/** @type {string} */ code) {
+ db.prepare(
+ 'UPDATE influencer_referral_codes SET click_count = click_count + 1 WHERE code = ? AND active = 1',
+ ).run(code);
+ }
+
+ return { getByCode, insert, listAll, setActive, incrementClicks };
+}
+
+export class InfluencerCodeError extends Error {
+ /**
+ * @param {string} message
+ * @param {string} code
+ * @param {number} status
+ */
+ constructor(message, code, status) {
+ super(message);
+ this.code = code;
+ this.status = status;
+ }
+}
+
+/**
+ * @param {{
+ * repository: ReturnType,
+ * baseUrl?: string,
+ * }} deps
+ */
+export function createInfluencerReferralService({ repository, baseUrl = 'https://trivela.network' }) {
+ const origin = baseUrl.replace(/\/+$/, '');
+ const withUrl = (/** @type {any} */ record) =>
+ record && { ...record, referralUrl: `${origin}/ref/${record.code}` };
+
+ /**
+ * Generate a referral code from an influencer's handle. The first code for a
+ * handle is the plain slug (`/ref/some-name`); further codes (e.g. per
+ * campaign) get a numeric suffix so links never collide.
+ * @param {{ handle: unknown, campaignId?: string|number|null }} input
+ */
+ function generate({ handle, campaignId = null }) {
+ const bare = normalizeHandle(handle);
+ if (!bare) {
+ throw new InfluencerCodeError(
+ 'handle must be a Twitter/X handle of 1-50 letters, digits or underscores',
+ 'INVALID_HANDLE',
+ 400,
+ );
+ }
+ const slug = slugifyHandle(bare);
+ if (!slug) {
+ throw new InfluencerCodeError('handle produces an empty code', 'INVALID_HANDLE', 400);
+ }
+ if (campaignId != null && !/^\d+$/.test(String(campaignId))) {
+ throw new InfluencerCodeError('campaignId must be a positive integer', 'INVALID_CAMPAIGN', 400);
+ }
+
+ for (let attempt = 1; attempt <= 100; attempt += 1) {
+ const candidate = attempt === 1 ? slug : `${slug}-${attempt}`;
+ const record = repository.insert(candidate, bare, campaignId);
+ if (record) return withUrl(record);
+ }
+ throw new InfluencerCodeError('could not allocate a unique code', 'CODE_EXHAUSTED', 409);
+ }
+
+ function list() {
+ return repository.listAll().map(withUrl);
+ }
+
+ /**
+ * Resolve a public code, counting the click. Inactive/unknown codes are 404.
+ * @param {unknown} code
+ */
+ function resolve(code) {
+ if (!isValidCode(code)) return null;
+ const record = repository.getByCode(/** @type {string} */ (code));
+ if (!record || !record.active) return null;
+ repository.incrementClicks(record.code);
+ return withUrl({ ...record, clickCount: record.clickCount + 1 });
+ }
+
+ /**
+ * @param {unknown} code
+ * @param {boolean} active
+ */
+ function setActive(code, active) {
+ if (!isValidCode(code) || !repository.getByCode(/** @type {string} */ (code))) {
+ throw new InfluencerCodeError('referral code not found', 'CODE_NOT_FOUND', 404);
+ }
+ return withUrl(repository.setActive(/** @type {string} */ (code), active));
+ }
+
+ return { generate, list, resolve, setActive };
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5d0ee5ab..296b1700 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -23,6 +23,7 @@ const TransactionHistory = lazy(() => import('./TransactionHistory'));
const ProofOfReserves = lazy(() => import('./ProofOfReserves'));
const EmbedCampaign = lazy(() => import('./pages/EmbedCampaign'));
const PublicProfile = lazy(() => import('./pages/PublicProfile'));
+const InfluencerReferralRedirect = lazy(() => import('./pages/InfluencerReferralRedirect'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const WebhookManagement = lazy(() => import('./pages/WebhookManagement'));
import { applyTheme, getPreferredTheme, THEME_STORAGE_KEY } from './theme';
@@ -427,6 +428,7 @@ export default function App() {
} />
} />
} />
+ } />
{
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await fetch(apiUrl(`/api/v1/referral-codes/${encodeURIComponent(code)}`));
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ try {
+ localStorage.setItem(
+ REFERRAL_STORAGE_KEY,
+ JSON.stringify({ code: data.code, influencerHandle: data.influencerHandle }),
+ );
+ } catch {
+ // storage unavailable (private mode) — attribution is best effort
+ }
+ if (!cancelled) {
+ setState({
+ status: 'ok',
+ target: data.campaignId ? `/campaigns/${data.campaignId}` : '/',
+ });
+ }
+ } catch {
+ if (!cancelled) setState({ status: 'invalid', target: '/' });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [code]);
+
+ if (state.status === 'loading') return Loading…
;
+ return ;
+}
diff --git a/frontend/src/pages/InfluencerReferralRedirect.test.jsx b/frontend/src/pages/InfluencerReferralRedirect.test.jsx
new file mode 100644
index 00000000..244461f1
--- /dev/null
+++ b/frontend/src/pages/InfluencerReferralRedirect.test.jsx
@@ -0,0 +1,46 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import InfluencerReferralRedirect, { REFERRAL_STORAGE_KEY } from './InfluencerReferralRedirect';
+
+function renderAt(path) {
+ return render(
+
+
+ } />
+ campaign page
} />
+ home page} />
+
+ ,
+ );
+}
+
+describe('InfluencerReferralRedirect', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it('stores attribution and redirects to the campaign', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ code: 'alice', influencerHandle: 'Alice', campaignId: '7' }),
+ }),
+ );
+ renderAt('/ref/alice');
+ await screen.findByText('campaign page');
+ expect(JSON.parse(localStorage.getItem(REFERRAL_STORAGE_KEY))).toEqual({
+ code: 'alice',
+ influencerHandle: 'Alice',
+ });
+ });
+
+ it('redirects home without storing for an unknown code', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
+ renderAt('/ref/nobody');
+ await waitFor(() => expect(screen.getByText('home page')).toBeTruthy());
+ expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull();
+ });
+});