From 076a19205781c5cb3fbf5952db31b342e5a71a13 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 11:08:23 +0100 Subject: [PATCH 01/13] feat: add campaign metrics CSV/JSON export endpoint (#463) --- backend/src/routes/campaignExport.js | 172 +++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 backend/src/routes/campaignExport.js diff --git a/backend/src/routes/campaignExport.js b/backend/src/routes/campaignExport.js new file mode 100644 index 00000000..6846eaa4 --- /dev/null +++ b/backend/src/routes/campaignExport.js @@ -0,0 +1,172 @@ +// @ts-check +import { Router } from 'express'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +const EXPORT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; +const EXPORT_RATE_LIMIT_MAX = 5; + +/** @type {Map} */ +const _exportBuckets = new Map(); + +function checkExportRateLimit(campaignId, actorKey) { + const key = `${campaignId}:${actorKey}`; + const now = Date.now(); + const bucket = _exportBuckets.get(key); + + if (!bucket || bucket.resetAt <= now) { + _exportBuckets.set(key, { count: 1, resetAt: now + EXPORT_RATE_LIMIT_WINDOW_MS }); + return { allowed: true, remaining: EXPORT_RATE_LIMIT_MAX - 1, resetAt: now + EXPORT_RATE_LIMIT_WINDOW_MS }; + } + if (bucket.count >= EXPORT_RATE_LIMIT_MAX) { + return { allowed: false, remaining: 0, resetAt: bucket.resetAt }; + } + bucket.count += 1; + return { allowed: true, remaining: EXPORT_RATE_LIMIT_MAX - bucket.count, resetAt: bucket.resetAt }; +} + +/** + * RFC 4180-compliant CSV serializer. + * @param {string[]} columns + * @param {Record[]} rows + */ +function buildCsv(columns, rows) { + const escape = (v) => { + const s = v === null || v === undefined ? '' : String(v); + return s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r') + ? '"' + s.replace(/"/g, '""') + '"' + : s; + }; + const lines = [columns.join(',')]; + for (const row of rows) lines.push(columns.map((c) => escape(row[c])).join(',')); + return lines.join('\n') + '\n'; +} + +/** + * @param {{ + * db: import('better-sqlite3').Database, + * campaignRepository: import('../dal/campaignRepository.js').CampaignRepository, + * auditLogRepository: import('../dal/auditLogRepository.js').AuditLogRepository, + * requireApiKey: import('express').RequestHandler, + * }} options + */ +export function createCampaignExportRoute({ db, campaignRepository, auditLogRepository, requireApiKey }) { + const router = Router(); + + router.get('/campaigns/:id/export', requireApiKey, async (req, res) => { + const { id } = req.params; + const format = String(req.query.format ?? 'csv').toLowerCase(); + const fromDate = typeof req.query.from === 'string' ? req.query.from : null; + const toDate = typeof req.query.to === 'string' ? req.query.to : null; + + if (format !== 'csv' && format !== 'json') { + return res.status(400).json({ error: 'Invalid format. Use ?format=csv or ?format=json', code: 'INVALID_FORMAT' }); + } + + const campaign = campaignRepository.getById(id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'NOT_FOUND' }); + } + + const actorKey = String(req.headers['x-api-key'] ?? req.query.api_key ?? req.ip ?? 'anon'); + const { allowed, remaining, resetAt } = checkExportRateLimit(id, actorKey); + res.setHeader('X-RateLimit-Limit', String(EXPORT_RATE_LIMIT_MAX)); + res.setHeader('X-RateLimit-Remaining', String(remaining)); + + if (!allowed) { + res.setHeader('Retry-After', String(Math.ceil((resetAt - Date.now()) / 1000))); + return res.status(429).json({ + error: 'Export rate limit exceeded. Max 5 exports per campaign per hour.', + code: 'EXPORT_RATE_LIMIT_EXCEEDED', + }); + } + + const hasCreditEvents = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='credit_events'").get(); + const hasClaimEvents = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='claim_events'").get(); + + let participants = []; + + if (hasCreditEvents) { + const dateFilters = []; + const vals = [String(id)]; + if (fromDate) { dateFilters.push("r.created_at >= ?"); vals.push(fromDate); } + if (toDate) { dateFilters.push("r.created_at <= ?"); vals.push(toDate); } + const dateWhere = dateFilters.length ? `AND ${dateFilters.join(' AND ')}` : ''; + + participants = db.prepare(` + WITH participants AS ( + SELECT DISTINCT user FROM credit_events + ), + credited AS ( + SELECT user, SUM(CAST(amount AS INTEGER)) AS total FROM credit_events GROUP BY user + ), + claimed AS ( + SELECT user, SUM(CAST(amount AS INTEGER)) AS total FROM claim_events GROUP BY user + ) + SELECT + p.user AS participantAddress, + r.created_at AS registeredAt, + COALESCE(cr.total, 0) AS pointsCredited, + COALESCE(cl.total, 0) AS pointsClaimed, + COALESCE(cr.total, 0) - COALESCE(cl.total, 0) AS netPoints, + ref.referrer_address AS referredBy + FROM participants p + LEFT JOIN referrals r + ON r.referee_address = p.user AND r.campaign_id = ? ${dateWhere} + LEFT JOIN credited cr ON cr.user = p.user + LEFT JOIN ${hasClaimEvents ? 'claimed' : '(SELECT NULL AS user, 0 AS total) dummy_cl'} cl ON cl.user = p.user + LEFT JOIN referrals ref + ON ref.referee_address = p.user AND ref.campaign_id = ? + `).all(...vals, String(id)); + } else { + // Fall back to referrals-only when event tables haven't been created yet + const dateFilters = []; + const vals = [String(id)]; + if (fromDate) { dateFilters.push("created_at >= ?"); vals.push(fromDate); } + if (toDate) { dateFilters.push("created_at <= ?"); vals.push(toDate); } + const dateWhere = dateFilters.length ? `AND ${dateFilters.join(' AND ')}` : ''; + + const rows = db.prepare(` + SELECT referee_address, referrer_address, created_at + FROM referrals + WHERE campaign_id = ? ${dateWhere} + ORDER BY created_at ASC + `).all(...vals); + + participants = rows.map((row) => ({ + participantAddress: row.referee_address, + registeredAt: row.created_at, + pointsCredited: 0, + pointsClaimed: 0, + netPoints: 0, + referredBy: row.referrer_address ?? null, + })); + } + + try { + auditLogRepository.create({ + actor: actorKey, + action: 'campaign.export', + entity: 'campaign', + entityId: id, + diff: { format, fromDate, toDate, rowCount: participants.length }, + }); + } catch (_err) { /* non-fatal */ } + + const filename = `campaign-${id}-export.${format}`; + + if (format === 'csv') { + const columns = ['participantAddress', 'registeredAt', 'pointsCredited', 'pointsClaimed', 'netPoints', 'referredBy']; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await pipeline(Readable.from([buildCsv(columns, participants)]), res); + } else { + const payload = JSON.stringify({ campaign: { id: campaign.id, name: campaign.name }, participants }, null, 2); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await pipeline(Readable.from([payload]), res); + } + }); + + return router; +} From b233cb9c4f2ad7440d0c0aff2d5b0dc47521aa6a Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 11:08:26 +0100 Subject: [PATCH 02/13] feat: add API deprecation registry (#466) --- backend/src/deprecations.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/src/deprecations.js diff --git a/backend/src/deprecations.js b/backend/src/deprecations.js new file mode 100644 index 00000000..b89de675 --- /dev/null +++ b/backend/src/deprecations.js @@ -0,0 +1,17 @@ +// @ts-check + +/** + * Deprecation registry — maps route patterns to lifecycle metadata. + * Add entries here before removing or replacing any endpoint. + * + * @type {Record} + */ +export const DEPRECATION_REGISTRY = { + // Example — uncomment when real deprecations land: + // 'GET /api/v1/campaigns/:id/stats': { + // deprecatedAt: '2024-09-01', + // removedAt: '2024-12-01', + // replacement: '/api/v1/campaigns/:id/analytics', + // message: 'Use the /analytics endpoint for richer campaign stats.', + // }, +}; From ba852de29afd0fdb7e667d2ee4fa7eada2b38336 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 11:08:28 +0100 Subject: [PATCH 03/13] feat: add deprecation notice middleware with RFC 8594 headers (#466) --- backend/src/middleware/deprecationNotice.js | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 backend/src/middleware/deprecationNotice.js diff --git a/backend/src/middleware/deprecationNotice.js b/backend/src/middleware/deprecationNotice.js new file mode 100644 index 00000000..c933f9db --- /dev/null +++ b/backend/src/middleware/deprecationNotice.js @@ -0,0 +1,66 @@ +// @ts-check +import { DEPRECATION_REGISTRY } from '../deprecations.js'; + +/** + * Match a request path+method against the deprecation registry. + * Registry keys are like "GET /api/v1/campaigns/:id/stats"; path segments + * starting with ":" are treated as wildcards. + * + * @param {string} method e.g. "GET" + * @param {string} path e.g. "/api/v1/campaigns/42/stats" + * @returns {import('../deprecations.js').DeprecationEntry | null} + */ +function matchDeprecation(method, path) { + for (const [pattern, entry] of Object.entries(DEPRECATION_REGISTRY)) { + const [patternMethod, ...rest] = pattern.split(' '); + const patternPath = rest.join(' '); + + if (patternMethod.toUpperCase() !== method.toUpperCase()) continue; + + const patternParts = patternPath.split('/'); + const pathParts = path.split('/'); + + if (patternParts.length !== pathParts.length) continue; + + const matched = patternParts.every( + (seg, i) => seg.startsWith(':') || seg === pathParts[i], + ); + + if (matched) return entry; + } + return null; +} + +/** + * Express middleware that injects RFC 8594 deprecation headers for + * any route registered in the deprecation registry, and WARN-logs usage + * so operators know which deprecated endpoints are still being hit. + * + * @param {{ log?: { warn?: Function } }} [options] + * @returns {import('express').RequestHandler} + */ +export function createDeprecationMiddleware({ log = console } = {}) { + return function deprecationNotice(req, res, next) { + const entry = matchDeprecation(req.method, req.path); + + if (entry) { + const deprecationDate = new Date(entry.deprecatedAt).toUTCString(); + const sunsetDate = new Date(entry.removedAt).toUTCString(); + + res.setHeader('Deprecation', deprecationDate); + res.setHeader('Sunset', sunsetDate); + res.setHeader( + 'Link', + `<${entry.replacement}>; rel="successor-version"`, + ); + + log.warn?.( + `deprecated_endpoint_hit method=${req.method} path=${req.path} ` + + `deprecated_at=${entry.deprecatedAt} removed_at=${entry.removedAt} ` + + `replacement=${entry.replacement}`, + ); + } + + next(); + }; +} From 2cd5ed94da424c65eb98f97730537cc433ed58b4 Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 11:08:43 +0100 Subject: [PATCH 04/13] feat: wire export route and deprecation middleware into index.js (#463 #466) --- backend/src/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/index.js b/backend/src/index.js index 357d0e4f..adbe1a06 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -46,6 +46,9 @@ import { MAX_IMAGE_SIZE_BYTES, } from './services/imageUpload.js'; import { buildCampaignStats } from './services/campaignStatsService.js'; +import { createCampaignExportRoute } from './routes/campaignExport.js'; +import { createDeprecationMiddleware } from './middleware/deprecationNotice.js'; +import { DEPRECATION_REGISTRY } from './deprecations.js'; import { generateAllowlist } from './lib/allowlist/merkle.js'; import { parseAllowlistCsv, validateGAddress, MAX_ALLOWLIST_ROWS } from './lib/allowlist/csv.js'; import { createEmbedRoute } from './routes/embed.js'; @@ -496,6 +499,7 @@ export async function createApp(options = {}) { app.use(compression({ threshold: 1024 })); app.use(cors(createCorsOptions(allowedOrigins))); app.use(securityHeaders); + app.use(createDeprecationMiddleware({ log })); app.use(traceparentMiddleware()); app.use(requestLogger); app.use(express.json({ limit: jsonBodyLimit })); @@ -1760,6 +1764,8 @@ export async function createApp(options = {}) { app.get(`${prefix}/campaigns/by-slug/:slug`, rateLimiter, getCampaignBySlug); app.get(`${prefix}/campaigns/:id`, rateLimiter, getCampaignById); app.get(`${prefix}/campaigns/:id/stats`, rateLimiter, getCampaignStats); + app.use(prefix, createCampaignExportRoute({ db: dal.db, campaignRepository, auditLogRepository, requireApiKey })); + app.get(`${prefix}/deprecations`, rateLimiter, (_req, res) => res.json({ deprecations: DEPRECATION_REGISTRY })); app.get(`${prefix}/audit-logs`, rateLimiter, ...guard, listAuditLogs); app.get(`${prefix}/admin/audit/verify`, rateLimiter, requireMasterKey, verifyAuditChain); app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState); From a88ac9ee0d7d2c7318f4ec0f9061b7e58d37b0fd Mon Sep 17 00:00:00 2001 From: Jaydbrown Date: Sun, 28 Jun 2026 11:08:53 +0100 Subject: [PATCH 05/13] feat: add user profile page with stats, activity, and share (#473) --- frontend/src/pages/UserProfile.jsx | 223 +++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 frontend/src/pages/UserProfile.jsx diff --git a/frontend/src/pages/UserProfile.jsx b/frontend/src/pages/UserProfile.jsx new file mode 100644 index 00000000..8dd1e4ba --- /dev/null +++ b/frontend/src/pages/UserProfile.jsx @@ -0,0 +1,223 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiUrl } from '../config'; +import PageMeta from '../components/PageMeta'; +import Header from '../components/Header'; + +function truncateAddress(addr) { + if (!addr || addr.length <= 14) return addr ?? ''; + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +function StatCard({ label, value }) { + return ( +
+ {value} + {label} +
+ ); +} + +function Skeleton({ width = '100%', height = '1.2em' }) { + return ( +