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
1 change: 1 addition & 0 deletions apps/api/src/constants/notification-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
12 changes: 12 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -353,6 +363,8 @@ async function start() {
startImporterMetricsScheduler();
startContractEventsPartitionScheduler();
startSlaBreachChecker();
startApiKeyUsagePruneScheduler();
startOnboardingDripScheduler();
app.listen(env.PORT, () => {
logger.info(
{
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/jobs/prune-api-key-usage.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
117 changes: 117 additions & 0 deletions apps/api/src/migrations/0008_dev_usage_and_onboarding_drip.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
// ── #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<void> => {
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`);
};
10 changes: 10 additions & 0 deletions apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions apps/api/src/routes/developer.ts
Original file line number Diff line number Diff line change
@@ -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<KeyRow[]> {
const res = await pool.query<KeyRow>(
`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,
});
});
Loading