diff --git a/apps/api/src/constants/notification-kinds.ts b/apps/api/src/constants/notification-kinds.ts index a1516c1..1fa54c0 100644 --- a/apps/api/src/constants/notification-kinds.ts +++ b/apps/api/src/constants/notification-kinds.ts @@ -12,6 +12,7 @@ export const NOTIFICATION_KINDS = { UPGRADE_APPROVED: 'upgrade_approved', UPGRADE_CANCELLED: 'upgrade_cancelled', SLA_BREACH: 'sla_breach', + ONBOARDING_DRIP: 'onboarding_drip', } as const; export type NotificationKind = (typeof NOTIFICATION_KINDS)[keyof typeof NOTIFICATION_KINDS]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4248ae1..43ce4e0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -34,6 +34,11 @@ import { notificationsRouter } from './routes/notifications.js'; import { upgradeSubscriptionsRouter } from './routes/upgrade-subscriptions.js'; import { bondAnnotationsRouter } from './routes/bond-annotations.js'; import { slaRouter } from './routes/sla.js'; +import { developerRouter } from './routes/developer.js'; +import { onboardingRouter } from './routes/onboarding.js'; +import { apiKeyUsageMeter } from './services/api-key-usage.js'; +import { startApiKeyUsagePruneScheduler } from './jobs/prune-api-key-usage.js'; +import { startOnboardingDripScheduler } from './services/onboarding-drip.js'; const app = express(); app.use(httpLogger); @@ -254,6 +259,9 @@ app.use( app.use(express.json({ limit: '1mb' })); +// #1043 — meter traffic that presents a recognised API key (no-op otherwise). +app.use(apiKeyUsageMeter); + const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, @@ -326,6 +334,8 @@ app.use('/notifications', notificationsRouter); app.use('/upgrade-subscriptions', upgradeSubscriptionsRouter); app.use('/bond-annotations', bondAnnotationsRouter); app.use('/sla', slaRouter); +app.use('/developer', developerRouter); +app.use('/onboarding', onboardingRouter); app.use('/api/v1/regulatory', regulatoryRouter); app.use('/bonds', bondWebhookRouter); // unauthenticated DocuSign webhook app.use('/api', bondSignaturesRouter); // authenticated bond signature routes @@ -353,6 +363,8 @@ async function start() { startImporterMetricsScheduler(); startContractEventsPartitionScheduler(); startSlaBreachChecker(); + startApiKeyUsagePruneScheduler(); + startOnboardingDripScheduler(); app.listen(env.PORT, () => { logger.info( { diff --git a/apps/api/src/jobs/prune-api-key-usage.ts b/apps/api/src/jobs/prune-api-key-usage.ts new file mode 100644 index 0000000..2184655 --- /dev/null +++ b/apps/api/src/jobs/prune-api-key-usage.ts @@ -0,0 +1,22 @@ +import { logger } from '../lib/logger.js'; +import { pruneApiKeyUsage } from '../services/api-key-usage.js'; + +/** + * Issue #1043 — keep `api_key_usage` bounded. Historical usage is retained for + * 30 days; older minute buckets are swept daily. + */ +export function startApiKeyUsagePruneScheduler(): void { + const INTERVAL_MS = 24 * 60 * 60 * 1000; + + async function sweep(): Promise { + try { + const deleted = await pruneApiKeyUsage(30); + if (deleted > 0) logger.info({ deleted }, 'pruned expired api_key_usage rows'); + } catch (err) { + logger.error({ err }, 'api_key_usage prune failed'); + } + } + + sweep(); + setInterval(sweep, INTERVAL_MS); +} diff --git a/apps/api/src/migrations/0008_dev_usage_and_onboarding_drip.ts b/apps/api/src/migrations/0008_dev_usage_and_onboarding_drip.ts new file mode 100644 index 0000000..a2f5e72 --- /dev/null +++ b/apps/api/src/migrations/0008_dev_usage_and_onboarding_drip.ts @@ -0,0 +1,117 @@ +// 0008_dev_usage_and_onboarding_drip.ts +// Adds tables for: +// - Issue #1043: Developer dashboard for API key usage and rate-limit status +// - Issue #1044: Automated onboarding email drip campaign for new signups +// +// Migration: 0008_dev_usage_and_onboarding_drip +// Date: 2026-08-28 + +import type { PoolClient } from 'pg'; + +export const up = async (client: PoolClient): Promise => { + // ── #1043: per-API-key request metering ────────────────────────────────── + // + // One row per (key, endpoint category, minute). Minute granularity keeps the + // rate-limit indicator meaningful while 30-day retention (see + // jobs/prune-api-key-usage.ts) bounds the row count to ~43k per key/category. + await client.query(` + CREATE TABLE IF NOT EXISTS api_key_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + endpoint_category TEXT NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (api_key_id, endpoint_category, window_start) + ) + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS idx_api_key_usage_key_window + ON api_key_usage (api_key_id, window_start DESC) + `); + + // Retention sweep predicate. + await client.query(` + CREATE INDEX IF NOT EXISTS idx_api_key_usage_window + ON api_key_usage (window_start) + `); + + // Optional per-key ceiling. NULL = no configured limit (the dashboard then + // only reports volume, no quota indicator). + await client.query(` + ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS rate_limit_per_min INTEGER + `); + + // ── #1044: onboarding drip campaign ───────────────────────────────────── + + await client.query(` + CREATE TABLE IF NOT EXISTS onboarding_drip_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + step_key TEXT NOT NULL UNIQUE, + position INTEGER NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + delay_hours INTEGER NOT NULL DEFAULT 0, + -- action the step nudges toward; when the importer has already done it + -- the step is skipped instead of sent. + completion_check TEXT NOT NULL CHECK (completion_check IN ('kyc', 'deposit', 'tariff', 'none')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS onboarding_drip_enrollments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + unsubscribed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS idx_onboarding_drip_enrollments_open + ON onboarding_drip_enrollments (enrolled_at) + WHERE completed_at IS NULL AND unsubscribed_at IS NULL + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS onboarding_drip_sends ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + enrollment_id UUID NOT NULL REFERENCES onboarding_drip_enrollments(id) ON DELETE CASCADE, + step_key TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('sent', 'skipped')), + sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (enrollment_id, step_key) + ) + `); + + // Default sequence — admin-editable afterwards via PUT /onboarding/drip/steps/:stepKey. + await client.query(` + INSERT INTO onboarding_drip_steps (step_key, position, subject, body, delay_hours, completion_check) + VALUES + ('complete_kyc', 1, 'Finish verifying your business', + 'Welcome to TariffShield! Your next step is to complete KYC so your bond can go active. It takes about 5 minutes.', + 1, 'kyc'), + ('first_deposit', 2, 'Fund your bond collateral', + 'Your account is ready for its first deposit. Add collateral to activate coverage for your import bond.', + 72, 'deposit'), + ('upload_tariff', 3, 'Upload your tariff CSV', + 'Upload your annual duty estimate so TariffShield can size your required collateral automatically.', + 168, 'tariff') + ON CONFLICT (step_key) DO NOTHING + `); +}; + +export const down = async (client: PoolClient): Promise => { + await client.query(`DROP TABLE IF EXISTS onboarding_drip_sends`); + await client.query(`DROP TABLE IF EXISTS onboarding_drip_enrollments`); + await client.query(`DROP TABLE IF EXISTS onboarding_drip_steps`); + await client.query(`DROP TABLE IF EXISTS api_key_usage`); + await client.query(`ALTER TABLE api_keys DROP COLUMN IF EXISTS rate_limit_per_min`); +}; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 6099789..efc30a6 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -24,6 +24,8 @@ import { type AuthedRequest, } from '../auth.js'; import { env } from '../config/env.js'; +import { enrollInOnboardingDrip } from '../services/onboarding-drip.js'; +import { logger } from '../lib/logger.js'; import { createHash, randomBytes } from 'crypto'; export const authRouter = Router(); @@ -132,6 +134,14 @@ authRouter.post('/signup', async (req: Request, res: Response) => { ); } + // #1044 — enrol importers into the onboarding drip sequence. Best-effort: + // a failure here must not fail signup. + if (role === 'importer') { + await enrollInOnboardingDrip(u.id).catch((err) => { + logger.error({ err, userId: u.id }, 'onboarding drip enrolment failed'); + }); + } + const sessionId = await createSession( u.id, req.ip ?? undefined, diff --git a/apps/api/src/routes/developer.ts b/apps/api/src/routes/developer.ts new file mode 100644 index 0000000..7c397df --- /dev/null +++ b/apps/api/src/routes/developer.ts @@ -0,0 +1,87 @@ +import { Router, type Request, type Response } from 'express'; +import { pool } from '../db.js'; +import { + authMiddleware, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; +import { getApiKeyUsageSummary } from '../services/api-key-usage.js'; + +/** + * Issue #1043 — developer dashboard endpoints. + * + * GET /developer/keys → the caller's API keys (metadata only) + * GET /developer/keys/:id/usage → usage rollup for one key + * GET /developer/usage → usage rollup across all the caller's keys + */ +export const developerRouter = Router(); +developerRouter.use(authMiddleware); +developerRouter.use(privacyReacceptanceGate); +developerRouter.use(tosReacceptanceGate); + +interface KeyRow { + id: string; + prefix: string; + label: string | null; + scopes: string[]; + rate_limit_per_min: number | null; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; + created_at: string; +} + +async function listKeys(userId: string): Promise { + const res = await pool.query( + `SELECT id, prefix, label, scopes, rate_limit_per_min, + last_used_at, expires_at, revoked_at, created_at + FROM api_keys + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId] + ); + return res.rows; +} + +developerRouter.get('/keys', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + res.json({ keys: await listKeys(user.id) }); +}); + +developerRouter.get('/keys/:id/usage', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const keys = await listKeys(user.id); + const key = keys.find((k) => k.id === String(req.params.id)); + if (!key) { + res.status(404).json({ error: 'API key not found' }); + return; + } + const summary = await getApiKeyUsageSummary({ + apiKeyId: key.id, + keyIds: [key.id], + rateLimitPerMin: key.rate_limit_per_min, + }); + res.json({ usage: summary }); +}); + +developerRouter.get('/usage', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const keys = await listKeys(user.id); + const active = keys.filter((k) => !k.revoked_at); + // Aggregate quota is the tightest configured per-key limit, if any. + const limits = active + .map((k) => k.rate_limit_per_min) + .filter((v): v is number => typeof v === 'number'); + const rateLimitPerMin = limits.length ? Math.min(...limits) : null; + + const summary = await getApiKeyUsageSummary({ + apiKeyId: null, + keyIds: active.map((k) => k.id), + rateLimitPerMin, + }); + res.json({ + usage: summary, + keyCount: active.length, + }); +}); diff --git a/apps/api/src/routes/onboarding.ts b/apps/api/src/routes/onboarding.ts new file mode 100644 index 0000000..b38e9f7 --- /dev/null +++ b/apps/api/src/routes/onboarding.ts @@ -0,0 +1,112 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; +import { unsubscribeFromOnboardingDrip } from '../services/onboarding-drip.js'; + +/** + * Issue #1044 — onboarding drip campaign endpoints. + * + * GET /onboarding/drip → the caller's enrollment + send log + * POST /onboarding/drip/unsubscribe → opt out (transactional notifications unaffected) + * GET /onboarding/drip/steps → sequence config (surety_admin) + * PUT /onboarding/drip/steps/:stepKey → edit a step (surety_admin) + */ +export const onboardingRouter = Router(); +onboardingRouter.use(authMiddleware); +onboardingRouter.use(privacyReacceptanceGate); +onboardingRouter.use(tosReacceptanceGate); + +onboardingRouter.get('/drip', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const enrollment = await pool.query( + `SELECT id, enrolled_at, completed_at, unsubscribed_at + FROM onboarding_drip_enrollments WHERE user_id = $1`, + [user.id] + ); + if (enrollment.rowCount === 0) { + res.json({ enrolled: false, sends: [] }); + return; + } + const enr = enrollment.rows[0]!; + const sends = await pool.query( + `SELECT s.step_key, s.status, s.sent_at, st.subject + FROM onboarding_drip_sends s + LEFT JOIN onboarding_drip_steps st ON st.step_key = s.step_key + WHERE s.enrollment_id = $1 + ORDER BY s.sent_at ASC`, + [enr.id] + ); + res.json({ + enrolled: true, + enrolledAt: enr.enrolled_at, + completedAt: enr.completed_at, + unsubscribedAt: enr.unsubscribed_at, + sends: sends.rows, + }); +}); + +onboardingRouter.post('/drip/unsubscribe', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + await unsubscribeFromOnboardingDrip(user.id); + res.json({ success: true }); +}); + +onboardingRouter.get( + '/drip/steps', + requireRole('surety_admin'), + async (_req: Request, res: Response) => { + const steps = await pool.query( + `SELECT step_key, position, subject, body, delay_hours, completion_check, is_active, updated_at + FROM onboarding_drip_steps + ORDER BY position ASC` + ); + res.json({ steps: steps.rows }); + } +); + +const StepUpdateSchema = z + .object({ + subject: z.string().min(1).max(200).optional(), + body: z.string().min(1).max(4000).optional(), + delay_hours: z.number().int().min(0).max(8760).optional(), + is_active: z.boolean().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { message: 'no fields to update' }); + +onboardingRouter.put( + '/drip/steps/:stepKey', + requireRole('surety_admin'), + async (req: Request, res: Response) => { + const parse = StepUpdateSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(parse.data)) { + sets.push(`${k} = $${sets.length + 1}`); + vals.push(v); + } + vals.push(String(req.params.stepKey)); + const updated = await pool.query( + `UPDATE onboarding_drip_steps + SET ${sets.join(', ')}, updated_at = now() + WHERE step_key = $${vals.length} + RETURNING step_key, position, subject, body, delay_hours, completion_check, is_active, updated_at`, + vals + ); + if (updated.rowCount === 0) { + res.status(404).json({ error: 'step not found' }); + return; + } + res.json({ step: updated.rows[0] }); + } +); diff --git a/apps/api/src/services/api-key-usage.ts b/apps/api/src/services/api-key-usage.ts new file mode 100644 index 0000000..a39b2b9 --- /dev/null +++ b/apps/api/src/services/api-key-usage.ts @@ -0,0 +1,235 @@ +import { createHash } from 'crypto'; +import type { Request, Response, NextFunction } from 'express'; +import { pool } from '../db.js'; +import { logger } from '../lib/logger.js'; + +/** + * Issue #1043 — API key request metering + rate-limit visibility. + * + * There is no API-key auth gate in front of the REST API today; this module + * only *meters* traffic that presents an `X-Api-Key` header (matching a row in + * `api_keys`) so integrators get a usage dashboard. It never rejects a + * request — enforcement, if added later, is a separate concern. + */ + +export const ENDPOINT_CATEGORIES = [ + 'importers', + 'bonds', + 'compliance', + 'kyc', + 'notifications', + 'admin', + 'auth', + 'other', +] as const; + +export type EndpointCategory = (typeof ENDPOINT_CATEGORIES)[number]; + +/** First path segment → coarse category for the usage breakdown. */ +export function categorizePath(path: string): EndpointCategory { + const seg = path.replace(/^\/+/, '').split('/')[0]?.toLowerCase() ?? ''; + switch (seg) { + case 'importers': + return 'importers'; + case 'bonds': + case 'bond-annotations': + case 'bond-signatures': + return 'bonds'; + case 'compliance': + case 'regulatory': + case 'privacy': + case 'account': + return 'compliance'; + case 'kyc': + return 'kyc'; + case 'notifications': + case 'upgrade-subscriptions': + return 'notifications'; + case 'admin': + return 'admin'; + case 'auth': + return 'auth'; + default: + return 'other'; + } +} + +function hashApiKey(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** Truncate to the start of the minute the timestamp falls in. */ +function minuteWindow(when: Date): Date { + const d = new Date(when); + d.setSeconds(0, 0); + return d; +} + +/** + * Increment the request counter for a key in the current minute bucket. + * Best-effort: a metering failure must never affect the request itself. + */ +export async function recordApiKeyUsage( + apiKeyId: string, + category: EndpointCategory, + when: Date = new Date() +): Promise { + try { + await pool.query( + `INSERT INTO api_key_usage (api_key_id, endpoint_category, window_start, request_count) + VALUES ($1, $2, $3, 1) + ON CONFLICT (api_key_id, endpoint_category, window_start) + DO UPDATE SET request_count = api_key_usage.request_count + 1`, + [apiKeyId, category, minuteWindow(when)] + ); + await pool.query(`UPDATE api_keys SET last_used_at = now() WHERE id = $1`, [apiKeyId]); + } catch (err) { + logger.warn({ err, apiKeyId }, 'api key usage metering failed'); + } +} + +interface KeyRow { + id: string; + rate_limit_per_min: number | null; +} + +async function resolveApiKey(rawKey: string): Promise { + const res = await pool.query( + `SELECT id, rate_limit_per_min FROM api_keys + WHERE key_hash = $1 AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > now())`, + [hashApiKey(rawKey)] + ); + return res.rows[0] ?? null; +} + +/** + * Express middleware: meter requests that carry a recognised API key. + * Mounted globally; a no-op for browser/session traffic. + */ +export function apiKeyUsageMeter(req: Request, res: Response, next: NextFunction): void { + const rawKey = req.header('x-api-key'); + if (!rawKey) { + next(); + return; + } + const category = categorizePath(req.path); + res.on('finish', () => { + void (async () => { + const key = await resolveApiKey(rawKey).catch(() => null); + if (key) await recordApiKeyUsage(key.id, category); + })(); + }); + next(); +} + +export interface UsageBucket { + windowStart: string; + requestCount: number; +} + +export interface ApiKeyUsageSummary { + apiKeyId: string | null; + rateLimitPerMin: number | null; + currentMinuteCount: number; + remaining: number | null; + /** true when the current minute is at or above 80% of the configured limit. */ + approachingLimit: boolean; + last24hByHour: UsageBucket[]; + last30dByDay: UsageBucket[]; + last24hByCategory: { category: string; requestCount: number }[]; +} + +const APPROACHING_LIMIT_RATIO = 0.8; + +/** + * Usage rollup for a single key, or — when `apiKeyId` is null — aggregated + * across every key id in `keyIds` (the caller's whole key set). + */ +export async function getApiKeyUsageSummary(opts: { + apiKeyId: string | null; + keyIds: string[]; + rateLimitPerMin: number | null; +}): Promise { + const { apiKeyId, keyIds, rateLimitPerMin } = opts; + const ids = apiKeyId ? [apiKeyId] : keyIds; + + const empty: ApiKeyUsageSummary = { + apiKeyId, + rateLimitPerMin, + currentMinuteCount: 0, + remaining: rateLimitPerMin, + approachingLimit: false, + last24hByHour: [], + last30dByDay: [], + last24hByCategory: [], + }; + if (ids.length === 0) return empty; + + const [byHour, byDay, byCategory, currentMinute] = await Promise.all([ + pool.query( + `SELECT date_trunc('hour', window_start) AS bucket, SUM(request_count)::int AS count + FROM api_key_usage + WHERE api_key_id = ANY($1) AND window_start >= now() - interval '24 hours' + GROUP BY bucket ORDER BY bucket`, + [ids] + ), + pool.query( + `SELECT date_trunc('day', window_start) AS bucket, SUM(request_count)::int AS count + FROM api_key_usage + WHERE api_key_id = ANY($1) AND window_start >= now() - interval '30 days' + GROUP BY bucket ORDER BY bucket`, + [ids] + ), + pool.query( + `SELECT endpoint_category, SUM(request_count)::int AS count + FROM api_key_usage + WHERE api_key_id = ANY($1) AND window_start >= now() - interval '24 hours' + GROUP BY endpoint_category ORDER BY count DESC`, + [ids] + ), + pool.query( + `SELECT COALESCE(SUM(request_count), 0)::int AS count + FROM api_key_usage + WHERE api_key_id = ANY($1) AND window_start = date_trunc('minute', now())`, + [ids] + ), + ]); + + const currentMinuteCount: number = currentMinute.rows[0]?.count ?? 0; + const remaining = + rateLimitPerMin == null ? null : Math.max(0, rateLimitPerMin - currentMinuteCount); + const approachingLimit = + rateLimitPerMin != null && + rateLimitPerMin > 0 && + currentMinuteCount >= rateLimitPerMin * APPROACHING_LIMIT_RATIO; + + return { + apiKeyId, + rateLimitPerMin, + currentMinuteCount, + remaining, + approachingLimit, + last24hByHour: byHour.rows.map((r) => ({ + windowStart: new Date(r.bucket).toISOString(), + requestCount: r.count, + })), + last30dByDay: byDay.rows.map((r) => ({ + windowStart: new Date(r.bucket).toISOString(), + requestCount: r.count, + })), + last24hByCategory: byCategory.rows.map((r) => ({ + category: r.endpoint_category, + requestCount: r.count, + })), + }; +} + +/** Delete usage rows older than the retention window (issue #1043: retain ≥30 days). */ +export async function pruneApiKeyUsage(retentionDays = 30): Promise { + const res = await pool.query( + `DELETE FROM api_key_usage WHERE window_start < now() - ($1 || ' days')::interval`, + [String(retentionDays)] + ); + return res.rowCount ?? 0; +} diff --git a/apps/api/src/services/onboarding-drip.ts b/apps/api/src/services/onboarding-drip.ts new file mode 100644 index 0000000..17a4632 --- /dev/null +++ b/apps/api/src/services/onboarding-drip.ts @@ -0,0 +1,178 @@ +import { pool, createNotification } from '../db.js'; +import { NOTIFICATION_KINDS } from '../constants/notification-kinds.js'; +import { logger } from '../lib/logger.js'; + +/** + * Issue #1044 — automated onboarding drip campaign. + * + * On signup an importer is enrolled (see routes/auth.ts). A scheduler walks the + * `onboarding_drip_steps` sequence: when a step comes due it is either sent + * (as an in-app notification — the platform's delivery primitive, see + * services/upgrade-notifications.ts) or skipped if the importer has already + * done the thing it nudges toward. Once every active step is resolved the + * enrollment is marked complete and no longer processed. Importers can + * unsubscribe without affecting transactional notifications. + */ + +export type CompletionCheck = 'kyc' | 'deposit' | 'tariff' | 'none'; + +interface DripStep { + step_key: string; + position: number; + subject: string; + body: string; + delay_hours: number; + completion_check: CompletionCheck; +} + +export async function enrollInOnboardingDrip(userId: string): Promise { + await pool.query( + `INSERT INTO onboarding_drip_enrollments (user_id) VALUES ($1) + ON CONFLICT (user_id) DO NOTHING`, + [userId] + ); +} + +export async function unsubscribeFromOnboardingDrip(userId: string): Promise { + await pool.query( + `UPDATE onboarding_drip_enrollments + SET unsubscribed_at = now() + WHERE user_id = $1 AND unsubscribed_at IS NULL`, + [userId] + ); +} + +/** Whether the importer for `userId` has already performed the step's target action. */ +export async function hasCompletedAction( + userId: string, + check: CompletionCheck +): Promise { + if (check === 'none') return false; + if (check === 'kyc') { + const r = await pool.query( + `SELECT 1 FROM importers WHERE user_id = $1 AND kyc_status = 'approved' LIMIT 1`, + [userId] + ); + return (r.rowCount ?? 0) > 0; + } + if (check === 'deposit') { + const r = await pool.query( + `SELECT 1 + FROM contract_events ce + JOIN importers i ON i.id = ce.importer_id + WHERE i.user_id = $1 + AND ce.kind IN ('deposit', 'deposit_collateral', 'deposit_reserve', 'auto_top_up') + LIMIT 1`, + [userId] + ); + return (r.rowCount ?? 0) > 0; + } + // tariff + const r = await pool.query( + `SELECT 1 FROM tariff_uploads t + JOIN importers i ON i.id = t.importer_id + WHERE i.user_id = $1 LIMIT 1`, + [userId] + ); + return (r.rowCount ?? 0) > 0; +} + +async function activeSteps(): Promise { + const r = await pool.query( + `SELECT step_key, position, subject, body, delay_hours, completion_check + FROM onboarding_drip_steps + WHERE is_active = TRUE + ORDER BY position ASC` + ); + return r.rows; +} + +interface OpenEnrollment { + id: string; + user_id: string; + enrolled_at: string; +} + +/** + * One scheduler pass. For each open enrollment, resolve at most one due step + * (send or skip). Marks the enrollment complete when every active step has a + * send row. + */ +export async function processOnboardingDrip(): Promise { + const steps = await activeSteps(); + if (steps.length === 0) return; + + const enrollments = await pool.query( + `SELECT id, user_id, enrolled_at + FROM onboarding_drip_enrollments + WHERE completed_at IS NULL AND unsubscribed_at IS NULL` + ); + + for (const enr of enrollments.rows) { + try { + const sentRows = await pool.query<{ step_key: string }>( + `SELECT step_key FROM onboarding_drip_sends WHERE enrollment_id = $1`, + [enr.id] + ); + const resolved = new Set(sentRows.rows.map((r) => r.step_key)); + + if (steps.every((s) => resolved.has(s.step_key))) { + await pool.query( + `UPDATE onboarding_drip_enrollments SET completed_at = now() WHERE id = $1`, + [enr.id] + ); + continue; + } + + const enrolledAt = new Date(enr.enrolled_at).getTime(); + const now = Date.now(); + + for (const step of steps) { + if (resolved.has(step.step_key)) continue; + const dueAt = enrolledAt + step.delay_hours * 3_600_000; + if (now < dueAt) break; // steps are sequential — wait for this one + + const done = await hasCompletedAction(enr.user_id, step.completion_check); + if (done) { + await recordSend(enr.id, step.step_key, 'skipped'); + } else { + await createNotification( + enr.user_id, + NOTIFICATION_KINDS.ONBOARDING_DRIP, + `${step.subject} — ${step.body}` + ); + await recordSend(enr.id, step.step_key, 'sent'); + } + break; // one step per enrollment per pass + } + } catch (err) { + logger.error({ err, enrollmentId: enr.id }, 'onboarding drip step failed'); + } + } +} + +async function recordSend( + enrollmentId: string, + stepKey: string, + status: 'sent' | 'skipped' +): Promise { + await pool.query( + `INSERT INTO onboarding_drip_sends (enrollment_id, step_key, status) + VALUES ($1, $2, $3) + ON CONFLICT (enrollment_id, step_key) DO NOTHING`, + [enrollmentId, stepKey, status] + ); +} + +export function startOnboardingDripScheduler(): void { + const INTERVAL_MS = 15 * 60 * 1000; + async function tick(): Promise { + try { + await processOnboardingDrip(); + } catch (err) { + logger.error({ err }, 'onboarding drip scheduler pass failed'); + } + } + tick(); + setInterval(tick, INTERVAL_MS); +} diff --git a/apps/web/app/developer/page.tsx b/apps/web/app/developer/page.tsx new file mode 100644 index 0000000..ce75679 --- /dev/null +++ b/apps/web/app/developer/page.tsx @@ -0,0 +1,41 @@ +'use client'; + +// Client Component for the same reason as app/page.tsx: auth is a pure +// client-side mechanism (JWT in localStorage), so the authenticated fetches +// the usage dashboard depends on can only run in the browser. +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Nav } from '@/components/Nav'; +import { DeveloperUsageDashboard } from '@/components/DeveloperUsageDashboard'; +import { isAuthenticated } from '@/lib/auth'; + +export default function DeveloperPage() { + const router = useRouter(); + const [ready, setReady] = useState(false); + + useEffect(() => { + if (!isAuthenticated()) { + router.replace('/login'); + return; + } + setReady(true); + }, [router]); + + if (!ready) return null; + + return ( +
+
+ ); +} diff --git a/apps/web/components/DepositWizard.tsx b/apps/web/components/DepositWizard.tsx index 9720f18..636c5f0 100644 --- a/apps/web/components/DepositWizard.tsx +++ b/apps/web/components/DepositWizard.tsx @@ -24,10 +24,15 @@ export function DepositWizard({ const [txHash, setTxHash] = useState(null); const [busy, setBusy] = useState(false); - function handleCancel() { + function resetWizard() { setStep('amount'); setXlm('50'); setTxHash(null); + setBusy(false); + } + + function handleCancel() { + resetWizard(); onCancel?.(); } @@ -136,6 +141,23 @@ export function DepositWizard({

{txHash.slice(0, 16)}…

)} + {/* #1049 — the receipt is no longer a dead end: the user can start + another deposit (state reset to the amount step, clearing the + previous txHash) or dismiss the wizard entirely. */} +
+ + +
)} diff --git a/apps/web/components/DeveloperUsageDashboard.tsx b/apps/web/components/DeveloperUsageDashboard.tsx new file mode 100644 index 0000000..ab3d6ed --- /dev/null +++ b/apps/web/components/DeveloperUsageDashboard.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { api, type ApiKeyUsageSummary, type DeveloperKey } from '@/lib/api'; +import { formatApiError } from '@/lib/error-formatter'; + +/** + * Issue #1043 — Developer dashboard: recent API-call volume, per-category + * breakdown, and how close the caller is to any configured per-minute limit. + */ +export function DeveloperUsageDashboard() { + const [usage, setUsage] = useState(null); + const [keys, setKeys] = useState([]); + const [keyCount, setKeyCount] = useState(0); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const [u, k] = await Promise.all([api.developerUsage(), api.developerKeys()]); + if (cancelled) return; + setUsage(u.usage); + setKeyCount(u.keyCount); + setKeys(k.keys); + } catch (e) { + if (!cancelled) setError(formatApiError(e).userMessage); + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + const t = setInterval(load, 30_000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, []); + + if (loading) return

Loading usage…

; + if (error) return

{error}

; + if (!usage) return null; + + const total24h = usage.last24hByHour.reduce((s, b) => s + b.requestCount, 0); + const total30d = usage.last30dByDay.reduce((s, b) => s + b.requestCount, 0); + + return ( +
+ {keyCount === 0 && ( +
+ No active API keys. Create a key to start collecting usage data. +
+ )} + + {usage.approachingLimit && usage.rateLimitPerMin != null && ( +
+

Approaching rate limit

+

+ {usage.currentMinuteCount} / {usage.rateLimitPerMin} requests this minute ( + {Math.round((usage.currentMinuteCount / usage.rateLimitPerMin) * 100)}%). +

+
+ )} + +
+ + + +
+ + {usage.rateLimitPerMin != null && ( +
+

+ Remaining quota this minute: {usage.remaining ?? 0} / {usage.rateLimitPerMin} +

+ +
+ )} + +
+

Requests per hour (last 24h)

+ +
+ + {usage.last24hByCategory.length > 0 && ( +
+

By endpoint category (24h)

+
    + {usage.last24hByCategory.map((c) => ( +
  • + {c.category} + {c.requestCount.toLocaleString()} +
  • + ))} +
+
+ )} + + {keys.length > 0 && ( +
+

Keys

+
    + {keys.map((k) => ( +
  • + + {k.prefix}… {k.label ? `(${k.label})` : ''} + {k.revoked_at ? ' — revoked' : ''} + + + {k.rate_limit_per_min != null ? `${k.rate_limit_per_min}/min` : 'no limit'} + +
  • + ))} +
+
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function QuotaBar({ used, limit }: { used: number; limit: number }) { + const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + const color = pct >= 80 ? 'bg-danger' : 'bg-success'; + return ( +
+
+
+ ); +} + +function BarChart({ buckets }: { buckets: { windowStart: string; requestCount: number }[] }) { + if (buckets.length === 0) { + return

No requests in this window.

; + } + const width = 480; + const height = 120; + const max = Math.max(1, ...buckets.map((b) => b.requestCount)); + const barW = width / Math.max(buckets.length, 1); + + return ( +
+ + {buckets.map((b, i) => { + const h = (b.requestCount / max) * (height - 16); + return ( + + + {new Date(b.windowStart).toLocaleString()}: {b.requestCount} + + + ); + })} + +
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index cadba97..0d4ac3a 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -76,6 +76,35 @@ export interface BondAnnotation { updatedAt: string; } +// ── Developer usage dashboard (#1043) ────────────────────────────────────── +export interface UsageBucket { + windowStart: string; + requestCount: number; +} + +export interface ApiKeyUsageSummary { + apiKeyId: string | null; + rateLimitPerMin: number | null; + currentMinuteCount: number; + remaining: number | null; + approachingLimit: boolean; + last24hByHour: UsageBucket[]; + last30dByDay: UsageBucket[]; + last24hByCategory: { category: string; requestCount: number }[]; +} + +export interface DeveloperKey { + id: string; + prefix: string; + label: string | null; + scopes: string[]; + rate_limit_per_min: number | null; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; + created_at: string; +} + async function request( path: string, options: { method?: string; body?: unknown; auth?: boolean } = {} @@ -191,6 +220,25 @@ export const api = { }), deleteAnnotation: (id: string) => request<{ success: boolean }>(`/bond-annotations/${id}`, { method: 'DELETE' }), + + // ── Developer usage dashboard (#1043) ──────────────────────────────────── + developerKeys: () => request<{ keys: DeveloperKey[] }>('/developer/keys'), + developerUsage: () => + request<{ usage: ApiKeyUsageSummary; keyCount: number }>('/developer/usage'), + developerKeyUsage: (id: string) => + request<{ usage: ApiKeyUsageSummary }>(`/developer/keys/${id}/usage`), + + // ── Onboarding drip (#1044) ────────────────────────────────────────────── + onboardingDrip: () => + request<{ + enrolled: boolean; + enrolledAt?: string; + completedAt?: string | null; + unsubscribedAt?: string | null; + sends: { step_key: string; status: string; sent_at: string; subject: string | null }[]; + }>('/onboarding/drip'), + onboardingDripUnsubscribe: () => + request<{ success: boolean }>('/onboarding/drip/unsubscribe', { method: 'POST' }), }; export function stroopsToXlm(stroops: string | bigint | number): string {