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(); + }); +});