Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- Backend: asynchronous campaign export worker (`POST /campaigns/:id/exports`) on the durable job
queue (#1260), Horizon deposit watcher for operator reward-token deposits (#1261), IP-cluster
fraud detection with an admin review API (#1258), and a generated OpenAPI reference with Swagger
UI at `/docs/api` (#1259). See `docs/BACKEND_ANALYTICS_AND_GROWTH.md`.
- Production deployment guide with environment matrix
- Error codes documentation with frontend-friendly message mapping
- Release process documentation with semantic versioning policy
Expand Down
13 changes: 13 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,16 @@ VAPID_SUBJECT=mailto:notifications@trivela.app
# retweet and follow tasks before points are awarded. When unset,
# POST /api/v1/social-tasks/twitter/verify responds 503 TWITTER_NOT_CONFIGURED.
# TWITTER_BEARER_TOKEN=

# ── Fraud detection (#1258) ───────────────────────────────────────────────
# Distinct accounts registering from one IP subnet (/24 IPv4, /64 IPv6) within the
# window before a fraud flag is raised for review.
# FRAUD_SUBNET_THRESHOLD=3
# FRAUD_WINDOW_HOURS=24
# Known proxy / VPN exit nodes (comma-separated IPs or IPv4 CIDR ranges).
# FRAUD_PROXY_EXIT_LIST=

# ── Operator deposit watcher (#1261) ──────────────────────────────────────
# Contract-custody accounts to watch on Horizon for incoming reward-token deposits.
# The reward asset comes from REWARD_TOKEN_CODE / REWARD_TOKEN_ISSUER (default: XLM).
# OPERATOR_DEPOSIT_ACCOUNTS=
119 changes: 119 additions & 0 deletions backend/src/dal/sqliteExportJobRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// @ts-check
import { randomUUID } from 'node:crypto';

/**
* @typedef {{
* id: string,
* campaignId: string,
* format: 'csv' | 'json',
* fromDate: string | null,
* toDate: string | null,
* status: 'queued' | 'running' | 'completed' | 'failed',
* rowCount: number | null,
* fileUrl: string | null,
* fileKey: string | null,
* error: string | null,
* attempts: number,
* createdAt: string,
* startedAt: string | null,
* completedAt: string | null,
* }} ExportJob
*/

/** @returns {ExportJob | null} */
function rowToJob(row) {
if (!row) return null;
return {
id: row.id,
campaignId: row.campaign_id,
format: row.format,
fromDate: row.from_date ?? null,
toDate: row.to_date ?? null,
status: row.status,
rowCount: row.row_count ?? null,
fileUrl: row.file_url ?? null,
fileKey: row.file_key ?? null,
error: row.error ?? null,
attempts: row.attempts,
createdAt: row.created_at,
startedAt: row.started_at ?? null,
completedAt: row.completed_at ?? null,
};
}

/**
* Persistence for asynchronous campaign export jobs (#1260).
*
* @param {{ db: InstanceType<import('better-sqlite3')> }} params
*/
export function createSqliteExportJobRepository({ db }) {
const insertStmt = db.prepare(`
INSERT INTO export_jobs (id, campaign_id, format, from_date, to_date, status, created_at)
VALUES (@id, @campaignId, @format, @fromDate, @toDate, 'queued', @createdAt)
`);
const byIdStmt = db.prepare('SELECT * FROM export_jobs WHERE id = ?');
const listStmt = db.prepare(
'SELECT * FROM export_jobs WHERE campaign_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?',
);
const runningStmt = db.prepare(`
UPDATE export_jobs
SET status = 'running', started_at = ?, attempts = attempts + 1, error = NULL
WHERE id = ?
`);
const completedStmt = db.prepare(`
UPDATE export_jobs
SET status = 'completed', row_count = ?, file_url = ?, file_key = ?,
completed_at = ?, error = NULL
WHERE id = ?
`);
const failedStmt = db.prepare("UPDATE export_jobs SET status = 'failed', error = ? WHERE id = ?");

return {
/**
* @param {{ campaignId: string, format: 'csv' | 'json', fromDate?: string | null, toDate?: string | null }} input
* @returns {ExportJob}
*/
create({ campaignId, format, fromDate = null, toDate = null }) {
const id = randomUUID();
insertStmt.run({
id,
campaignId: String(campaignId),
format,
fromDate,
toDate,
createdAt: new Date().toISOString(),
});
return /** @type {ExportJob} */ (rowToJob(byIdStmt.get(id)));
},

/** @param {string} id */
getById(id) {
return rowToJob(byIdStmt.get(id));
},

/** @param {string | number} campaignId @param {number} [limit] */
listByCampaign(campaignId, limit = 20) {
return listStmt
.all(String(campaignId), Math.min(Math.max(limit, 1), 100))
.map((row) => /** @type {ExportJob} */ (rowToJob(row)));
},

/** @param {string} id */
markRunning(id) {
runningStmt.run(new Date().toISOString(), id);
},

/**
* @param {string} id
* @param {{ rowCount: number, fileUrl: string, fileKey?: string | null }} result
*/
markCompleted(id, { rowCount, fileUrl, fileKey = null }) {
completedStmt.run(rowCount, fileUrl, fileKey, new Date().toISOString(), id);
},

/** @param {string} id @param {string} error */
markFailed(id, error) {
failedStmt.run(error.slice(0, 500), id);
},
};
}
32 changes: 32 additions & 0 deletions backend/src/db/migrations/041_export_jobs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const version = 41;
export const description = 'Add export_jobs table for asynchronous campaign export processing';

/**
* Tracks campaign export requests that are processed off the request path by
* the durable job queue (#1260). Status moves queued -> running -> completed,
* or failed when the worker throws (the queue retries; a later attempt moves
* the row back to running).
*/
export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS export_jobs (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('csv', 'json')),
from_date TEXT,
to_date TEXT,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'completed', 'failed')),
row_count INTEGER,
file_url TEXT,
file_key TEXT,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_export_jobs_campaign
ON export_jobs(campaign_id, created_at DESC);
`);
}
34 changes: 34 additions & 0 deletions backend/src/db/migrations/042_operator_deposits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const version = 42;
export const description = 'Add operator_deposits and horizon_watcher_cursors for deposit watching';

/**
* Deposits observed by the Horizon account watcher (#1261). `paging_token` is
* unique so replayed stream events (after a reconnect) are idempotent.
*/
export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS operator_deposits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
paging_token TEXT NOT NULL UNIQUE,
account TEXT NOT NULL,
from_address TEXT,
asset_code TEXT NOT NULL,
asset_issuer TEXT,
amount TEXT NOT NULL,
tx_hash TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 1,
observed_at TEXT NOT NULL,
recorded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operator_deposits_account
ON operator_deposits(account, id DESC);
CREATE INDEX IF NOT EXISTS idx_operator_deposits_tx
ON operator_deposits(tx_hash);

CREATE TABLE IF NOT EXISTS horizon_watcher_cursors (
account TEXT PRIMARY KEY,
cursor TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
}
40 changes: 40 additions & 0 deletions backend/src/db/migrations/043_fraud_detection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const version = 43;
export const description = 'Add signup_events and fraud_flags for IP-cluster fraud detection';

/**
* Only the /24 (IPv4) or /64 (IPv6) network prefix of a signup's IP is stored
* (#1258), never the full address — except for known public proxy exit nodes,
* whose address is a published list entry rather than personal data.
*/
export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS signup_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id TEXT NOT NULL,
account TEXT NOT NULL,
ip_subnet TEXT NOT NULL,
proxy_exit TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_signup_events_subnet
ON signup_events(campaign_id, ip_subnet, created_at);
CREATE INDEX IF NOT EXISTS idx_signup_events_proxy
ON signup_events(campaign_id, proxy_exit, created_at);

CREATE TABLE IF NOT EXISTS fraud_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id TEXT NOT NULL,
rule TEXT NOT NULL CHECK (rule IN ('ip_subnet_cluster', 'proxy_exit_node')),
subject TEXT NOT NULL,
account_count INTEGER NOT NULL,
accounts TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'dismissed', 'confirmed')),
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE (campaign_id, rule, subject)
);
CREATE INDEX IF NOT EXISTS idx_fraud_flags_status
ON fraud_flags(status, last_seen_at DESC);
`);
}
Loading
Loading