diff --git a/backend/src/app.ts b/backend/src/app.ts index 7be190a..0bef30b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -28,6 +28,10 @@ import approvalRoutes from "./routes/approvals"; import successRateRoutes from "./routes/successRate"; import regionalFeeRoutes from "./routes/regionalFees"; import webhookRoutes from "./routes/webhooks"; +import traceRoutes from "./routes/traces"; +import verificationRoutes from "./routes/verification"; +import analyticsRoutes from "./routes/analytics"; +import thresholdRoutes from "./routes/thresholds"; import { config } from "./config"; import { logger } from "./logger"; import { createContainer } from "./container"; @@ -109,7 +113,10 @@ export async function buildApp() { await app.register(approvalRoutes, { prefix }); await app.register(successRateRoutes, { prefix }); await app.register(regionalFeeRoutes, { prefix }); - await app.register(webhookRoutes, { prefix }); + await app.register(traceRoutes, { prefix }); + await app.register(verificationRoutes, { prefix }); + await app.register(analyticsRoutes, { prefix }); + await app.register(thresholdRoutes, { prefix }); app.addHook("onResponse", async (request, reply) => { const latencyMs = Math.round(reply.elapsedTime ?? 0); diff --git a/backend/src/container.ts b/backend/src/container.ts index 704e681..b7fe4ab 100644 --- a/backend/src/container.ts +++ b/backend/src/container.ts @@ -41,6 +41,10 @@ import { receiptService } from "./modules/receipts/receiptService"; import { WebhookDispatcher } from "./modules/webhooks/webhookDispatcher"; import { WebhookService } from "./modules/webhooks/webhookService"; import { AdminAuditService } from "./modules/admin/adminAuditService"; +import { RequestTraceService } from "./modules/traces/requestTraceService"; +import { VerificationService } from "./modules/verification/verificationService"; +import { CashFlowAnalyticsService } from "./modules/analytics/cashFlowAnalyticsService"; +import { ApprovalThresholdService } from "./modules/approvals/approvalThresholdService"; export interface AppContainer { config: AppConfig; @@ -83,6 +87,10 @@ export interface AppContainer { stressTest: StressTestService; webhooks: WebhookService; adminAudit: AdminAuditService; + requestTrace: RequestTraceService; + verification: VerificationService; + cashFlowAnalytics: CashFlowAnalyticsService; + approvalThreshold: ApprovalThresholdService; }; } @@ -150,6 +158,10 @@ export function createContainer(): AppContainer { const webhookDispatcher = new WebhookDispatcher(eventBus); const webhooks = new WebhookService(eventBus); const adminAudit = new AdminAuditService(); + const requestTrace = new RequestTraceService(); + const verification = new VerificationService(); + const cashFlowAnalytics = new CashFlowAnalyticsService(); + const approvalThreshold = new ApprovalThresholdService(); recurringWorker.start(); stellarMonitor.start(); @@ -222,6 +234,10 @@ export function createContainer(): AppContainer { stressTest, webhooks, adminAudit, + requestTrace, + verification, + cashFlowAnalytics, + approvalThreshold, }, }; } diff --git a/backend/src/modules/analytics/cashFlowAnalyticsService.ts b/backend/src/modules/analytics/cashFlowAnalyticsService.ts new file mode 100644 index 0000000..9f1134c --- /dev/null +++ b/backend/src/modules/analytics/cashFlowAnalyticsService.ts @@ -0,0 +1,185 @@ +import { logger } from '../../logger'; + +export interface CashFlowSummary { + totalInflow: number; + totalOutflow: number; + netFlow: number; + inflowCount: number; + outflowCount: number; + averageInflow: number; + averageOutflow: number; + projectedInflow: number; + projectedOutflow: number; +} + +export interface MonthlyCashFlow { + month: string; + year: number; + monthLabel: string; + inflow: number; + outflow: number; + netFlow: number; + transactionCount: number; +} + +export interface CashFlowTrend { + daily: DailyCashFlow[]; + weekly: WeeklyCashFlow[]; + monthly: MonthlyCashFlow[]; +} + +export interface DailyCashFlow { + date: string; + inflow: number; + outflow: number; + netFlow: number; + transactionCount: number; +} + +export interface WeeklyCashFlow { + weekStart: string; + weekEnd: string; + inflow: number; + outflow: number; + netFlow: number; + transactionCount: number; +} + +export interface TopRecipient { + recipientId: string; + recipientName: string; + totalSent: number; + transactionCount: number; + averageAmount: number; +} + +export interface TopSource { + source: string; + totalReceived: number; + transactionCount: number; +} + +export class CashFlowAnalyticsService { + private dailyData: DailyCashFlow[] = []; + private monthlyData: MonthlyCashFlow[] = []; + + constructor() { + this.seedDemoData(); + } + + private seedDemoData() { + const now = new Date(); + const daily: DailyCashFlow[] = []; + const monthly: MonthlyCashFlow[] = []; + + for (let d = 89; d >= 0; d--) { + const date = new Date(now); + date.setDate(date.getDate() - d); + const dayStr = date.toISOString().slice(0, 10); + + const inflow = Math.round(Math.random() * 8000 + 500); + const outflow = Math.round(Math.random() * 6000 + 200); + daily.push({ + date: dayStr, + inflow, + outflow, + netFlow: inflow - outflow, + transactionCount: Math.floor(Math.random() * 30 + 5), + }); + } + + for (let m = 5; m >= 0; m--) { + const date = new Date(now.getFullYear(), now.getMonth() - m, 1); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + const label = date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + + const inflow = Math.round(Math.random() * 150000 + 30000); + const outflow = Math.round(Math.random() * 100000 + 20000); + monthly.push({ + month: String(month).padStart(2, '0'), + year, + monthLabel: label, + inflow, + outflow, + netFlow: inflow - outflow, + transactionCount: Math.floor(Math.random() * 500 + 100), + }); + } + + this.dailyData = daily; + this.monthlyData = monthly; + } + + getSummary(): CashFlowSummary { + const totalInflow = this.dailyData.reduce((sum, d) => sum + d.inflow, 0); + const totalOutflow = this.dailyData.reduce((sum, d) => sum + d.outflow, 0); + const inflowCount = this.dailyData.reduce((sum, d) => sum + d.transactionCount, 0); + + const avgInflow = this.dailyData.length > 0 + ? Math.round(totalInflow / this.dailyData.length) + : 0; + const avgOutflow = this.dailyData.length > 0 + ? Math.round(totalOutflow / this.dailyData.length) + : 0; + + const last30Inflow = this.dailyData.slice(-30).reduce((sum, d) => sum + d.inflow, 0); + const last30Outflow = this.dailyData.slice(-30).reduce((sum, d) => sum + d.outflow, 0); + + return { + totalInflow, + totalOutflow, + netFlow: totalInflow - totalOutflow, + inflowCount, + outflowCount: inflowCount, + averageInflow: avgInflow, + averageOutflow: avgOutflow, + projectedInflow: Math.round(last30Inflow * 3), + projectedOutflow: Math.round(last30Outflow * 3), + }; + } + + getMonthlyData(months = 6): MonthlyCashFlow[] { + return this.monthlyData.slice(-months); + } + + getTrend(days = 90): CashFlowTrend { + const daily = this.dailyData.slice(-days); + const weekly: WeeklyCashFlow[] = []; + + for (let i = 0; i < daily.length; i += 7) { + const week = daily.slice(i, i + 7); + if (week.length === 0) continue; + weekly.push({ + weekStart: week[0].date, + weekEnd: week[week.length - 1].date, + inflow: week.reduce((s, d) => s + d.inflow, 0), + outflow: week.reduce((s, d) => s + d.outflow, 0), + netFlow: week.reduce((s, d) => s + d.netFlow, 0), + transactionCount: week.reduce((s, d) => s + d.transactionCount, 0), + }); + } + + return { daily, weekly, monthly: this.monthlyData }; + } + + getTopRecipients(limit = 10): TopRecipient[] { + return [ + { recipientId: 'recipient_demo_1', recipientName: 'Maria Garcia', totalSent: 45000, transactionCount: 24, averageAmount: 1875 }, + { recipientId: 'recipient_demo_2', recipientName: 'John Okafor', totalSent: 120000, transactionCount: 12, averageAmount: 10000 }, + { recipientId: 'recipient_demo_3', recipientName: 'Aisha Patel', totalSent: 28000, transactionCount: 8, averageAmount: 3500 }, + { recipientId: 'recipient_demo_4', recipientName: 'Carlos Mendez', totalSent: 15000, transactionCount: 6, averageAmount: 2500 }, + { recipientId: 'recipient_demo_5', recipientName: 'Yuki Tanaka', totalSent: 9500, transactionCount: 5, averageAmount: 1900 }, + ].slice(0, limit); + } + + getTopSources(limit = 10): TopSource[] { + return [ + { source: 'Bank Transfer', totalReceived: 180000, transactionCount: 45 }, + { source: 'Card Payment', totalReceived: 95000, transactionCount: 120 }, + { source: 'Crypto Deposit', totalReceived: 50000, transactionCount: 18 }, + { source: 'Mobile Money', totalReceived: 35000, transactionCount: 60 }, + { source: 'Wire Transfer', totalReceived: 12000, transactionCount: 8 }, + ].slice(0, limit); + } +} diff --git a/backend/src/modules/approvals/approvalThresholdService.ts b/backend/src/modules/approvals/approvalThresholdService.ts new file mode 100644 index 0000000..1ddd680 --- /dev/null +++ b/backend/src/modules/approvals/approvalThresholdService.ts @@ -0,0 +1,257 @@ +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../../logger'; + +export type ThresholdRuleAction = 'require_approval' | 'require_second_approval' | 'require_compliance_review' | 'block' | 'allow'; +export type ThresholdRuleCondition = 'amount_above' | 'amount_below' | 'amount_range' | 'destination_country' | 'risk_score' | 'user_tier' | 'daily_volume'; + +export interface ThresholdRule { + id: string; + name: string; + description: string; + enabled: boolean; + priority: number; + condition: ThresholdRuleCondition; + config: Record; + action: ThresholdRuleAction; + approvalLevel: number; + notifyAdmins: boolean; + createdAt: string; + updatedAt: string; + createdBy?: string; +} + +export interface ApprovalLevel { + level: number; + name: string; + description: string; + requiredApprovers: number; + approverRoles: string[]; +} + +const DEFAULT_LEVELS: ApprovalLevel[] = [ + { level: 1, name: 'Standard', description: 'Requires one admin approval', requiredApprovers: 1, approverRoles: ['admin'] }, + { level: 2, name: 'Enhanced', description: 'Requires two admin approvals', requiredApprovers: 2, approverRoles: ['admin', 'senior_admin'] }, + { level: 3, name: 'Executive', description: 'Requires three senior approvals', requiredApprovers: 3, approverRoles: ['senior_admin', 'compliance_officer'] }, +]; + +export class ApprovalThresholdService { + private rules: ThresholdRule[] = []; + private approvalLevels: ApprovalLevel[] = DEFAULT_LEVELS; + + constructor() { + this.seedDefaultRules(); + } + + private seedDefaultRules() { + const now = new Date().toISOString(); + this.rules = [ + { + id: 'rule_default_1', + name: 'Large Transfer', + description: 'Transfers above $10,000 require approval', + enabled: true, + priority: 100, + condition: 'amount_above', + config: { amount: 10000, currency: 'USDC' }, + action: 'require_approval', + approvalLevel: 1, + notifyAdmins: true, + createdAt: now, + updatedAt: now, + }, + { + id: 'rule_default_2', + name: 'Very Large Transfer', + description: 'Transfers above $50,000 require second-level approval', + enabled: true, + priority: 90, + condition: 'amount_above', + config: { amount: 50000, currency: 'USDC' }, + action: 'require_second_approval', + approvalLevel: 2, + notifyAdmins: true, + createdAt: now, + updatedAt: now, + }, + { + id: 'rule_default_3', + name: 'High-Risk Destination', + description: 'Transfers to restricted countries require compliance review', + enabled: true, + priority: 80, + condition: 'destination_country', + config: { countries: ['RU', 'BY', 'IR', 'KP', 'VE', 'CU'] }, + action: 'require_compliance_review', + approvalLevel: 1, + notifyAdmins: true, + createdAt: now, + updatedAt: now, + }, + { + id: 'rule_default_4', + name: 'Block High Risk', + description: 'High risk score transfers are blocked', + enabled: true, + priority: 70, + condition: 'risk_score', + config: { minScore: 80, maxScore: 100 }, + action: 'block', + approvalLevel: 0, + notifyAdmins: true, + createdAt: now, + updatedAt: now, + }, + { + id: 'rule_default_5', + name: 'Small Transfer Bypass', + description: 'Transfers under $500 skip approval', + enabled: true, + priority: 60, + condition: 'amount_below', + config: { amount: 500, currency: 'USDC' }, + action: 'allow', + approvalLevel: 0, + notifyAdmins: false, + createdAt: now, + updatedAt: now, + }, + ]; + } + + getRules(): ThresholdRule[] { + return [...this.rules].sort((a, b) => b.priority - a.priority); + } + + getRuleById(ruleId: string): ThresholdRule | undefined { + return this.rules.find((r) => r.id === ruleId); + } + + createRule(input: { + name: string; + description: string; + condition: ThresholdRuleCondition; + config: Record; + action: ThresholdRuleAction; + approvalLevel: number; + priority: number; + notifyAdmins: boolean; + createdBy?: string; + }): ThresholdRule { + const now = new Date().toISOString(); + const rule: ThresholdRule = { + id: `threshold_rule_${uuidv4().slice(0, 8)}`, + name: input.name, + description: input.description, + enabled: true, + priority: input.priority, + condition: input.condition, + config: input.config, + action: input.action, + approvalLevel: input.approvalLevel, + notifyAdmins: input.notifyAdmins, + createdAt: now, + updatedAt: now, + createdBy: input.createdBy, + }; + + this.rules.push(rule); + return rule; + } + + updateRule(ruleId: string, input: Partial>): ThresholdRule | null { + const rule = this.rules.find((r) => r.id === ruleId); + if (!rule) return null; + + if (input.name !== undefined) rule.name = input.name; + if (input.description !== undefined) rule.description = input.description; + if (input.enabled !== undefined) rule.enabled = input.enabled; + if (input.priority !== undefined) rule.priority = input.priority; + if (input.condition !== undefined) rule.condition = input.condition; + if (input.config !== undefined) rule.config = input.config; + if (input.action !== undefined) rule.action = input.action; + if (input.approvalLevel !== undefined) rule.approvalLevel = input.approvalLevel; + if (input.notifyAdmins !== undefined) rule.notifyAdmins = input.notifyAdmins; + + rule.updatedAt = new Date().toISOString(); + return rule; + } + + deleteRule(ruleId: string): boolean { + const index = this.rules.findIndex((r) => r.id === ruleId); + if (index === -1) return false; + this.rules.splice(index, 1); + return true; + } + + toggleRule(ruleId: string): ThresholdRule | null { + const rule = this.rules.find((r) => r.id === ruleId); + if (!rule) return null; + rule.enabled = !rule.enabled; + rule.updatedAt = new Date().toISOString(); + return rule; + } + + getApprovalLevels(): ApprovalLevel[] { + return this.approvalLevels; + } + + evaluateRules(transfer: { + amount: number; + currency?: string; + destinationCountry?: string; + riskScore?: number; + userTier?: string; + dailyVolume?: number; + }): Array<{ rule: ThresholdRule; matched: boolean }> { + const sortedRules = [...this.rules] + .filter((r) => r.enabled) + .sort((a, b) => b.priority - a.priority); + + return sortedRules.map((rule) => { + let matched = false; + + switch (rule.condition) { + case 'amount_above': + matched = transfer.amount > (rule.config.amount as number); + break; + case 'amount_below': + matched = transfer.amount < (rule.config.amount as number); + break; + case 'amount_range': + matched = transfer.amount >= (rule.config.minAmount as number || 0) + && transfer.amount <= (rule.config.maxAmount as number || Infinity); + break; + case 'destination_country': + matched = (rule.config.countries as string[] || []) + .some((c) => c === transfer.destinationCountry?.toUpperCase()); + break; + case 'risk_score': + matched = (transfer.riskScore || 0) >= (rule.config.minScore as number || 0) + && (transfer.riskScore || 0) <= (rule.config.maxScore as number || 100); + break; + case 'user_tier': + matched = (rule.config.tiers as string[] || []) + .some((t) => t === transfer.userTier); + break; + case 'daily_volume': + matched = (transfer.dailyVolume || 0) > (rule.config.volume as number || 0); + break; + } + + return { rule, matched }; + }); + } + + getHighestRequiredLevel(amount: number, destinationCountry?: string, riskScore?: number): number { + const results = this.evaluateRules({ amount, destinationCountry, riskScore }); + + let maxLevel = 0; + for (const { rule, matched } of results) { + if (matched && rule.approvalLevel > maxLevel) { + maxLevel = rule.approvalLevel; + } + } + + return maxLevel; + } +} diff --git a/backend/src/modules/traces/requestTraceService.ts b/backend/src/modules/traces/requestTraceService.ts new file mode 100644 index 0000000..185d321 --- /dev/null +++ b/backend/src/modules/traces/requestTraceService.ts @@ -0,0 +1,209 @@ +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../../logger'; + +export type TraceMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +export type TraceStatus = 'success' | 'redirect' | 'client_error' | 'server_error'; + +export interface RequestTrace { + id: string; + correlationId: string; + method: TraceMethod; + path: string; + statusCode: number; + status: TraceStatus; + durationMs: number; + userId?: string; + errorMessage?: string; + requestHeaders?: Record; + responseHeaders?: Record; + requestBody?: string; + responseBody?: string; + ip?: string; + userAgent?: string; + timestamp: string; +} + +export interface TraceFilter { + correlationId?: string; + method?: TraceMethod; + status?: TraceStatus; + statusCode?: number; + userId?: string; + path?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +} + +export interface TraceStats { + total: number; + byMethod: Record; + byStatus: Record; + byPath: Record; + averageDurationMs: number; + p95DurationMs: number; + p99DurationMs: number; + errorRate: number; + lastMinuteCount: number; +} + +export class RequestTraceService { + private traces: RequestTrace[] = []; + private maxTraces = 10000; + + recordTrace(input: { + correlationId: string; + method: TraceMethod; + path: string; + statusCode: number; + durationMs: number; + userId?: string; + errorMessage?: string; + requestHeaders?: Record; + responseHeaders?: Record; + requestBody?: string; + responseBody?: string; + ip?: string; + userAgent?: string; + }): RequestTrace { + const trace: RequestTrace = { + id: `trace_${uuidv4().slice(0, 8)}`, + correlationId: input.correlationId, + method: input.method, + path: input.path, + statusCode: input.statusCode, + status: this.categorizeStatus(input.statusCode), + durationMs: input.durationMs, + userId: input.userId, + errorMessage: input.errorMessage, + requestHeaders: input.requestHeaders, + responseHeaders: input.responseHeaders, + requestBody: input.requestBody, + responseBody: input.responseBody, + ip: input.ip, + userAgent: input.userAgent, + timestamp: new Date().toISOString(), + }; + + this.traces.push(trace); + if (this.traces.length > this.maxTraces) { + this.traces = this.traces.slice(-this.maxTraces); + } + + logger.debug({ traceId: trace.id, correlationId: trace.correlationId, path: trace.path, durationMs: trace.durationMs }, 'Request trace recorded'); + + return trace; + } + + getTraces(filters?: TraceFilter): { traces: RequestTrace[]; total: number } { + let filtered = [...this.traces]; + + if (filters?.correlationId) { + filtered = filtered.filter((t) => + t.correlationId.toLowerCase().includes(filters.correlationId!.toLowerCase()), + ); + } + if (filters?.method) { + filtered = filtered.filter((t) => t.method === filters.method); + } + if (filters?.status) { + filtered = filtered.filter((t) => t.status === filters.status); + } + if (filters?.statusCode) { + filtered = filtered.filter((t) => t.statusCode === filters.statusCode); + } + if (filters?.userId) { + filtered = filtered.filter((t) => t.userId === filters.userId); + } + if (filters?.path) { + filtered = filtered.filter((t) => + t.path.toLowerCase().includes(filters.path!.toLowerCase()), + ); + } + if (filters?.startDate) { + const start = new Date(filters.startDate).getTime(); + filtered = filtered.filter((t) => new Date(t.timestamp).getTime() >= start); + } + if (filters?.endDate) { + const end = new Date(filters.endDate).getTime(); + filtered = filtered.filter((t) => new Date(t.timestamp).getTime() <= end); + } + + filtered.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + const total = filtered.length; + const offset = filters?.offset || 0; + const limit = filters?.limit || 50; + const traces = filtered.slice(offset, offset + limit); + + return { traces, total }; + } + + getTraceById(traceId: string): RequestTrace | undefined { + return this.traces.find((t) => t.id === traceId); + } + + getStats(): TraceStats { + const now = Date.now(); + const oneMinuteAgo = now - 60_000; + + const byMethod: Record = { + GET: 0, POST: 0, PUT: 0, PATCH: 0, DELETE: 0, + }; + const byStatus: Record = { + success: 0, redirect: 0, client_error: 0, server_error: 0, + }; + const byPath: Record = {}; + let totalDuration = 0; + let lastMinuteCount = 0; + const durations: number[] = []; + + for (const trace of this.traces) { + byMethod[trace.method]++; + byStatus[trace.status]++; + byPath[trace.path] = (byPath[trace.path] || 0) + 1; + totalDuration += trace.durationMs; + durations.push(trace.durationMs); + + if (new Date(trace.timestamp).getTime() > oneMinuteAgo) { + lastMinuteCount++; + } + } + + durations.sort((a, b) => a - b); + + const averageDurationMs = this.traces.length > 0 + ? Math.round(totalDuration / this.traces.length) + : 0; + const p95DurationMs = durations.length > 0 + ? durations[Math.ceil(durations.length * 0.95) - 1] || 0 + : 0; + const p99DurationMs = durations.length > 0 + ? durations[Math.ceil(durations.length * 0.99) - 1] || 0 + : 0; + const errorCount = byStatus.client_error + byStatus.server_error; + const errorRate = this.traces.length > 0 + ? Math.round((errorCount / this.traces.length) * 10000) / 100 + : 0; + + return { + total: this.traces.length, + byMethod, + byStatus, + byPath, + averageDurationMs, + p95DurationMs, + p99DurationMs, + errorRate, + lastMinuteCount, + }; + } + + private categorizeStatus(statusCode: number): TraceStatus { + if (statusCode < 300) return 'success'; + if (statusCode < 400) return 'redirect'; + if (statusCode < 500) return 'client_error'; + return 'server_error'; + } +} diff --git a/backend/src/modules/verification/verificationService.ts b/backend/src/modules/verification/verificationService.ts new file mode 100644 index 0000000..c58643b --- /dev/null +++ b/backend/src/modules/verification/verificationService.ts @@ -0,0 +1,307 @@ +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../../logger'; + +export type VerificationStatus = 'unverified' | 'pending' | 'verified' | 'flagged'; +export type VerificationMethod = 'phone' | 'email' | 'id_document' | 'bank_account' | 'address'; +export type BadgeType = 'verified_recipient' | 'trusted_recipient' | 'frequent_recipient' | 'business_verified' | 'new_recipient'; + +export interface VerificationBadge { + type: BadgeType; + label: string; + description: string; + icon: string; + color: string; + level: number; +} + +export interface RecipientVerification { + recipientId: string; + recipientName: string; + recipientPhone: string; + status: VerificationStatus; + badges: VerificationBadge[]; + methods: VerificationMethod[]; + verifiedAt?: string; + lastCheckedAt: string; + trustScore: number; + totalTransfers: number; + totalVolume: number; + firstTransferAt?: string; + flags?: string[]; + notes?: string; +} + +export interface VerificationRequest { + id: string; + recipientId: string; + recipientName: string; + recipientPhone: string; + requestedMethod: VerificationMethod; + status: 'pending' | 'approved' | 'rejected'; + requestedAt: string; + reviewedBy?: string; + reviewedAt?: string; + rejectionReason?: string; +} + +const BADGE_DEFINITIONS: Record> = { + verified_recipient: { + type: 'verified_recipient', + label: 'Verified', + description: 'Identity has been verified', + icon: 'CheckCircle2', + color: 'text-green-500', + }, + trusted_recipient: { + type: 'trusted_recipient', + label: 'Trusted', + description: 'Successfully received multiple transfers', + icon: 'Shield', + color: 'text-blue-500', + }, + frequent_recipient: { + type: 'frequent_recipient', + label: 'Frequent', + description: 'Regular transfer recipient', + icon: 'Zap', + color: 'text-purple-500', + }, + business_verified: { + type: 'business_verified', + label: 'Business', + description: 'Verified business account', + icon: 'Building2', + color: 'text-amber-500', + }, + new_recipient: { + type: 'new_recipient', + label: 'New', + description: 'First time recipient', + icon: 'UserPlus', + color: 'text-gray-400', + }, +}; + +export class VerificationService { + private verifications = new Map(); + private verificationRequests: VerificationRequest[] = []; + + constructor() { + this.seedDemoVerifications(); + } + + private seedDemoVerifications() { + const now = Date.now(); + const demo: RecipientVerification[] = [ + { + recipientId: 'recipient_demo_1', + recipientName: 'Maria Garcia', + recipientPhone: '+525512345678', + status: 'verified', + badges: [ + { ...BADGE_DEFINITIONS.verified_recipient, level: 3 }, + { ...BADGE_DEFINITIONS.trusted_recipient, level: 2 }, + { ...BADGE_DEFINITIONS.frequent_recipient, level: 1 }, + ], + methods: ['phone', 'id_document'], + verifiedAt: new Date(now - 7776000000).toISOString(), + lastCheckedAt: new Date().toISOString(), + trustScore: 92, + totalTransfers: 24, + totalVolume: 45000, + firstTransferAt: new Date(now - 15552000000).toISOString(), + }, + { + recipientId: 'recipient_demo_2', + recipientName: 'John Okafor', + recipientPhone: '+2348012345678', + status: 'verified', + badges: [ + { ...BADGE_DEFINITIONS.verified_recipient, level: 3 }, + { ...BADGE_DEFINITIONS.business_verified, level: 2 }, + ], + methods: ['phone', 'email', 'bank_account'], + verifiedAt: new Date(now - 5184000000).toISOString(), + lastCheckedAt: new Date().toISOString(), + trustScore: 88, + totalTransfers: 12, + totalVolume: 120000, + firstTransferAt: new Date(now - 10368000000).toISOString(), + }, + { + recipientId: 'recipient_demo_3', + recipientName: 'Wei Chen', + recipientPhone: '+8613800138000', + status: 'unverified', + badges: [ + { ...BADGE_DEFINITIONS.new_recipient, level: 0 }, + ], + methods: [], + lastCheckedAt: new Date().toISOString(), + trustScore: 10, + totalTransfers: 0, + totalVolume: 0, + }, + { + recipientId: 'recipient_demo_4', + recipientName: 'Sarah Johnson', + recipientPhone: '+447700900001', + status: 'pending', + badges: [ + { ...BADGE_DEFINITIONS.new_recipient, level: 0 }, + ], + methods: ['phone'], + lastCheckedAt: new Date().toISOString(), + trustScore: 30, + totalTransfers: 0, + totalVolume: 0, + }, + ]; + + demo.forEach((v) => this.verifications.set(v.recipientId, v)); + } + + getVerification(recipientId: string): RecipientVerification | undefined { + return this.verifications.get(recipientId); + } + + getAllVerifications(limit = 50): RecipientVerification[] { + return Array.from(this.verifications.values()).slice(0, limit); + } + + getVerificationsByStatus(status: VerificationStatus, limit = 50): RecipientVerification[] { + return Array.from(this.verifications.values()) + .filter((v) => v.status === status) + .slice(0, limit); + } + + createVerificationRequest(input: { + recipientId: string; + recipientName: string; + recipientPhone: string; + method: VerificationMethod; + }): VerificationRequest { + const existing = this.verificationRequests.find( + (r) => r.recipientId === input.recipientId && r.status === 'pending', + ); + if (existing) return existing; + + const request: VerificationRequest = { + id: `ver_req_${uuidv4().slice(0, 8)}`, + recipientId: input.recipientId, + recipientName: input.recipientName, + recipientPhone: input.recipientPhone, + requestedMethod: input.method, + status: 'pending', + requestedAt: new Date().toISOString(), + }; + + this.verificationRequests.push(request); + + const existingVerification = this.verifications.get(input.recipientId); + if (existingVerification) { + existingVerification.status = 'pending'; + existingVerification.methods = [...new Set([...existingVerification.methods, input.method])]; + existingVerification.lastCheckedAt = new Date().toISOString(); + } else { + this.verifications.set(input.recipientId, { + recipientId: input.recipientId, + recipientName: input.recipientName, + recipientPhone: input.recipientPhone, + status: 'pending', + badges: [{ ...BADGE_DEFINITIONS.new_recipient, level: 0 }], + methods: [input.method], + lastCheckedAt: new Date().toISOString(), + trustScore: 20, + totalTransfers: 0, + totalVolume: 0, + }); + } + + return request; + } + + approveVerification(verificationRequestId: string, reviewerId: string): VerificationRequest | null { + const request = this.verificationRequests.find((r) => r.id === verificationRequestId); + if (!request || request.status !== 'pending') return null; + + request.status = 'approved'; + request.reviewedBy = reviewerId; + request.reviewedAt = new Date().toISOString(); + + const verification = this.verifications.get(request.recipientId); + if (verification) { + verification.status = 'verified'; + verification.verifiedAt = new Date().toISOString(); + verification.lastCheckedAt = new Date().toISOString(); + if (!verification.badges.find((b) => b.type === 'verified_recipient')) { + verification.badges.push({ ...BADGE_DEFINITIONS.verified_recipient, level: 3 }); + } + verification.trustScore = Math.min(100, verification.trustScore + 40); + } + + return request; + } + + rejectVerification(verificationRequestId: string, reviewerId: string, reason: string): VerificationRequest | null { + const request = this.verificationRequests.find((r) => r.id === verificationRequestId); + if (!request || request.status !== 'pending') return null; + + request.status = 'rejected'; + request.reviewedBy = reviewerId; + request.reviewedAt = new Date().toISOString(); + request.rejectionReason = reason; + + return request; + } + + getVerificationRequests(limit = 50): VerificationRequest[] { + return this.verificationRequests.slice(0, limit); + } + + getVerificationRequestById(id: string): VerificationRequest | undefined { + return this.verificationRequests.find((r) => r.id === id); + } + + recalculateTrustScore(recipientId: string, transferAmount: number): void { + const verification = this.verifications.get(recipientId); + if (!verification) return; + + verification.totalTransfers += 1; + verification.totalVolume += transferAmount; + verification.lastCheckedAt = new Date().toISOString(); + + const badges: VerificationBadge[] = []; + + if (verification.status === 'verified') { + badges.push({ ...BADGE_DEFINITIONS.verified_recipient, level: 3 }); + } + + if (verification.totalTransfers >= 5) { + badges.push({ ...BADGE_DEFINITIONS.trusted_recipient, level: 2 }); + } + + if (verification.totalTransfers >= 10) { + badges.push({ ...BADGE_DEFINITIONS.frequent_recipient, level: 1 }); + } + + if (verification.methods.includes('bank_account') || verification.methods.includes('id_document')) { + badges.push({ ...BADGE_DEFINITIONS.business_verified, level: 2 }); + } + + if (badges.length === 0) { + badges.push({ ...BADGE_DEFINITIONS.new_recipient, level: 0 }); + } + + verification.badges = badges; + + const baseScore = verification.status === 'verified' ? 60 : 20; + const volumeScore = Math.min(20, verification.totalVolume / 10000); + const transferScore = Math.min(20, verification.totalTransfers * 2); + verification.trustScore = Math.min(100, baseScore + volumeScore + transferScore); + } + + getBadgeDefinitions(): Record> { + return BADGE_DEFINITIONS; + } +} diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts new file mode 100644 index 0000000..2d02a92 --- /dev/null +++ b/backend/src/routes/analytics.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from 'fastify'; +import { requireVerifiedSession } from '../middleware/authenticate'; + +export default async function analyticsRoutes(fastify: FastifyInstance) { + const authGuard = { preHandler: [requireVerifiedSession] }; + + fastify.get('/analytics/cash-flow/summary', authGuard, async () => { + return fastify.container.services.cashFlowAnalytics.getSummary(); + }); + + fastify.get('/analytics/cash-flow/monthly', authGuard, async (req) => { + const query = req.query as { months?: string }; + const months = Number(query.months) || 6; + return fastify.container.services.cashFlowAnalytics.getMonthlyData(months); + }); + + fastify.get('/analytics/cash-flow/trend', authGuard, async (req) => { + const query = req.query as { days?: string }; + const days = Number(query.days) || 90; + return fastify.container.services.cashFlowAnalytics.getTrend(days); + }); + + fastify.get('/analytics/cash-flow/top-recipients', authGuard, async (req) => { + const query = req.query as { limit?: string }; + const limit = Number(query.limit) || 10; + return fastify.container.services.cashFlowAnalytics.getTopRecipients(limit); + }); + + fastify.get('/analytics/cash-flow/top-sources', authGuard, async (req) => { + const query = req.query as { limit?: string }; + const limit = Number(query.limit) || 10; + return fastify.container.services.cashFlowAnalytics.getTopSources(limit); + }); +} diff --git a/backend/src/routes/thresholds.ts b/backend/src/routes/thresholds.ts new file mode 100644 index 0000000..fb34764 --- /dev/null +++ b/backend/src/routes/thresholds.ts @@ -0,0 +1,112 @@ +import type { FastifyInstance } from 'fastify'; +import { requireVerifiedSession } from '../middleware/authenticate'; +import { requireRole } from '../middleware/requireRole'; +import type { JwtSessionPayload } from '../auth/sessionTypes'; + +export default async function thresholdRoutes(fastify: FastifyInstance) { + const adminGuards = { preHandler: [requireVerifiedSession, requireRole('admin')] }; + + fastify.get('/admin/thresholds', adminGuards, async () => { + return fastify.container.services.approvalThreshold.getRules(); + }); + + fastify.get('/admin/thresholds/levels', adminGuards, async () => { + return fastify.container.services.approvalThreshold.getApprovalLevels(); + }); + + fastify.get<{ Params: { id: string } }>( + '/admin/thresholds/:id', + adminGuards, + async (req, reply) => { + const rule = fastify.container.services.approvalThreshold.getRuleById(req.params.id); + if (!rule) { + return reply.code(404).send({ error: 'Threshold rule not found' }); + } + return rule; + }, + ); + + fastify.post<{ + Body: { + name: string; + description: string; + condition: string; + config: Record; + action: string; + approvalLevel: number; + priority: number; + notifyAdmins: boolean; + }; + }>( + '/admin/thresholds', + adminGuards, + async (req, reply) => { + const body = req.body; + if (!body.name || !body.condition || !body.action) { + return reply.code(400).send({ error: 'name, condition, and action are required' }); + } + const payload = req.user as JwtSessionPayload; + const rule = fastify.container.services.approvalThreshold.createRule({ + name: body.name, + description: body.description || '', + condition: body.condition as any, + config: body.config || {}, + action: body.action as any, + approvalLevel: body.approvalLevel || 0, + priority: body.priority || 0, + notifyAdmins: body.notifyAdmins ?? true, + createdBy: payload.sub, + }); + return reply.code(201).send(rule); + }, + ); + + fastify.put<{ + Params: { id: string }; + Body: Partial<{ + name: string; + description: string; + condition: string; + config: Record; + action: string; + approvalLevel: number; + priority: number; + notifyAdmins: boolean; + enabled: boolean; + }>; + }>( + '/admin/thresholds/:id', + adminGuards, + async (req, reply) => { + const updated = fastify.container.services.approvalThreshold.updateRule(req.params.id, req.body as any); + if (!updated) { + return reply.code(404).send({ error: 'Threshold rule not found' }); + } + return updated; + }, + ); + + fastify.delete<{ Params: { id: string } }>( + '/admin/thresholds/:id', + adminGuards, + async (req, reply) => { + const deleted = fastify.container.services.approvalThreshold.deleteRule(req.params.id); + if (!deleted) { + return reply.code(404).send({ error: 'Threshold rule not found' }); + } + return { deleted: true }; + }, + ); + + fastify.post<{ Params: { id: string } }>( + '/admin/thresholds/:id/toggle', + adminGuards, + async (req, reply) => { + const toggled = fastify.container.services.approvalThreshold.toggleRule(req.params.id); + if (!toggled) { + return reply.code(404).send({ error: 'Threshold rule not found' }); + } + return toggled; + }, + ); +} diff --git a/backend/src/routes/traces.ts b/backend/src/routes/traces.ts new file mode 100644 index 0000000..b8c4476 --- /dev/null +++ b/backend/src/routes/traces.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from 'fastify'; +import { requireVerifiedSession } from '../middleware/authenticate'; +import { requireRole } from '../middleware/requireRole'; + +export default async function traceRoutes(fastify: FastifyInstance) { + const adminGuards = { preHandler: [requireVerifiedSession, requireRole('admin')] }; + + fastify.get('/admin/traces', adminGuards, async (req) => { + const query = req.query as { + correlationId?: string; + method?: string; + status?: string; + statusCode?: string; + userId?: string; + path?: string; + startDate?: string; + endDate?: string; + limit?: string; + offset?: string; + }; + + return fastify.container.services.requestTrace.getTraces({ + correlationId: query.correlationId, + method: query.method as any, + status: query.status as any, + statusCode: query.statusCode ? Number(query.statusCode) : undefined, + userId: query.userId, + path: query.path, + startDate: query.startDate, + endDate: query.endDate, + limit: query.limit ? Number(query.limit) : 50, + offset: query.offset ? Number(query.offset) : 0, + }); + }); + + fastify.get('/admin/traces/stats', adminGuards, async () => { + return fastify.container.services.requestTrace.getStats(); + }); + + fastify.get<{ Params: { id: string } }>( + '/admin/traces/:id', + adminGuards, + async (req, reply) => { + const trace = fastify.container.services.requestTrace.getTraceById(req.params.id); + if (!trace) { + return reply.code(404).send({ error: 'Trace not found' }); + } + return trace; + }, + ); +} diff --git a/backend/src/routes/verification.ts b/backend/src/routes/verification.ts new file mode 100644 index 0000000..29c9e3a --- /dev/null +++ b/backend/src/routes/verification.ts @@ -0,0 +1,104 @@ +import type { FastifyInstance } from 'fastify'; +import { requireVerifiedSession } from '../middleware/authenticate'; +import { requireRole } from '../middleware/requireRole'; +import type { JwtSessionPayload } from '../auth/sessionTypes'; + +export default async function verificationRoutes(fastify: FastifyInstance) { + const authGuard = { preHandler: [requireVerifiedSession] }; + const adminGuards = { preHandler: [requireVerifiedSession, requireRole('admin')] }; + + fastify.get<{ Params: { recipientId: string } }>( + '/verification/:recipientId', + authGuard, + async (req, reply) => { + const verification = fastify.container.services.verification.getVerification(req.params.recipientId); + if (!verification) { + return reply.code(404).send({ error: 'Recipient not found' }); + } + return verification; + }, + ); + + fastify.get('/verification/badges', authGuard, async () => { + return fastify.container.services.verification.getBadgeDefinitions(); + }); + + fastify.post<{ + Body: { + recipientId: string; + recipientName: string; + recipientPhone: string; + method: string; + }; + }>( + '/verification/request', + authGuard, + async (req, reply) => { + const body = req.body; + if (!body.recipientId || !body.recipientName || !body.recipientPhone || !body.method) { + return reply.code(400).send({ error: 'recipientId, recipientName, recipientPhone, and method are required' }); + } + const request = fastify.container.services.verification.createVerificationRequest({ + recipientId: body.recipientId, + recipientName: body.recipientName, + recipientPhone: body.recipientPhone, + method: body.method as any, + }); + return reply.code(201).send(request); + }, + ); + + fastify.get('/admin/verifications', adminGuards, async (req) => { + const query = req.query as { status?: string; limit?: string }; + const limit = Number(query.limit) || 50; + if (query.status) { + return fastify.container.services.verification.getVerificationsByStatus(query.status as any, limit); + } + return fastify.container.services.verification.getAllVerifications(limit); + }); + + fastify.get('/admin/verifications/requests', adminGuards, async (req) => { + const query = req.query as { limit?: string }; + const limit = Number(query.limit) || 50; + return fastify.container.services.verification.getVerificationRequests(limit); + }); + + fastify.get<{ Params: { id: string } }>( + '/admin/verifications/requests/:id', + adminGuards, + async (req, reply) => { + const request = fastify.container.services.verification.getVerificationRequestById(req.params.id); + if (!request) { + return reply.code(404).send({ error: 'Verification request not found' }); + } + return request; + }, + ); + + fastify.post<{ Params: { id: string } }>( + '/admin/verifications/requests/:id/approve', + adminGuards, + async (req, reply) => { + const reviewerId = (req.user as JwtSessionPayload).sub; + const result = fastify.container.services.verification.approveVerification(req.params.id, reviewerId); + if (!result) { + return reply.code(400).send({ error: 'Could not approve verification request' }); + } + return result; + }, + ); + + fastify.post<{ Params: { id: string }; Body: { reason: string } }>( + '/admin/verifications/requests/:id/reject', + adminGuards, + async (req, reply) => { + const reviewerId = (req.user as JwtSessionPayload).sub; + const reason = req.body?.reason || 'Rejected by admin'; + const result = fastify.container.services.verification.rejectVerification(req.params.id, reviewerId, reason); + if (!result) { + return reply.code(400).send({ error: 'Could not reject verification request' }); + } + return result; + }, + ); +} diff --git a/src/App.tsx b/src/App.tsx index 9251329..f2e59f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,10 @@ const AdminRegulatoryReports = lazy(() => import("./pages/AdminRegulatoryReports const AdminApiUsage = lazy(() => import("./pages/AdminApiUsage")); const ActivityHeatmap = lazy(() => import("./pages/ActivityHeatmap")); const InsightsDashboard = lazy(() => import("./pages/InsightsDashboard")); +const AdminApiTraces = lazy(() => import("./pages/AdminApiTraces")); +const RecipientVerification = lazy(() => import("./pages/RecipientVerification")); +const CashFlowAnalytics = lazy(() => import("./pages/CashFlowAnalytics")); +const AdminThresholdRules = lazy(() => import("./pages/AdminThresholdRules")); const VerificationFlow = lazy(() => import("./components/VerificationFlow").then((module) => ({ default: module.VerificationFlow, @@ -268,6 +272,38 @@ function AppRoutes() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/src/components/VerificationBadge.tsx b/src/components/VerificationBadge.tsx new file mode 100644 index 0000000..8f6aea4 --- /dev/null +++ b/src/components/VerificationBadge.tsx @@ -0,0 +1,96 @@ +import { CheckCircle2, Shield, Zap, Building2, UserPlus, type LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +export type BadgeType = 'verified_recipient' | 'trusted_recipient' | 'frequent_recipient' | 'business_verified' | 'new_recipient'; + +export interface VerificationBadgeData { + type: BadgeType; + label: string; + description: string; + color: string; + level: number; +} + +const badgeIcons: Record = { + verified_recipient: CheckCircle2, + trusted_recipient: Shield, + frequent_recipient: Zap, + business_verified: Building2, + new_recipient: UserPlus, +}; + +interface VerificationBadgeProps { + badge: VerificationBadgeData; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + className?: string; +} + +const sizeClasses = { + sm: { icon: 'w-3 h-3', text: 'text-[10px]', gap: 'gap-1' }, + md: { icon: 'w-4 h-4', text: 'text-xs', gap: 'gap-1.5' }, + lg: { icon: 'w-5 h-5', text: 'text-sm', gap: 'gap-2' }, +}; + +export function VerificationBadge({ badge, size = 'sm', showLabel = true, className }: VerificationBadgeProps) { + const Icon = badgeIcons[badge.type] || CheckCircle2; + const sizes = sizeClasses[size]; + + return ( + + + + + + {showLabel && ( + + {badge.label} + + )} + + + +

{badge.label}

+

{badge.description}

+ {badge.level > 0 && ( +

Level {badge.level} trust

+ )} +
+
+
+ ); +} + +interface BadgeListProps { + badges: VerificationBadgeData[]; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + limit?: number; + className?: string; +} + +export function BadgeList({ badges, size = 'sm', showLabel = true, limit, className }: BadgeListProps) { + const displayBadges = limit ? badges.slice(0, limit) : badges; + const remaining = limit ? Math.max(0, badges.length - limit) : 0; + + return ( +
+ {displayBadges.map((badge) => ( + + ))} + {remaining > 0 && ( + + +{remaining} + + )} +
+ ); +} diff --git a/src/pages/AdminApiTraces.tsx b/src/pages/AdminApiTraces.tsx new file mode 100644 index 0000000..30aa171 --- /dev/null +++ b/src/pages/AdminApiTraces.tsx @@ -0,0 +1,362 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Activity, Search, Filter, ChevronDown, ChevronRight, Clock, AlertCircle, CheckCircle2, XCircle } from 'lucide-react'; + +import { apiFetch } from '@/lib/api'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +interface RequestTrace { + id: string; + correlationId: string; + method: string; + path: string; + statusCode: number; + status: string; + durationMs: number; + userId?: string; + errorMessage?: string; + requestHeaders?: Record; + responseHeaders?: Record; + requestBody?: string; + responseBody?: string; + ip?: string; + userAgent?: string; + timestamp: string; +} + +interface TraceStats { + total: number; + byMethod: Record; + byStatus: Record; + averageDurationMs: number; + p95DurationMs: number; + p99DurationMs: number; + errorRate: number; + lastMinuteCount: number; +} + +export default function AdminApiTraces() { + const [traces, setTraces] = useState([]); + const [total, setTotal] = useState(0); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [searchCorrelationId, setSearchCorrelationId] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + const [filterMethod, setFilterMethod] = useState(''); + + const fetchTraces = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (searchCorrelationId) params.set('correlationId', searchCorrelationId); + if (filterStatus) params.set('status', filterStatus); + if (filterMethod) params.set('method', filterMethod); + params.set('limit', '100'); + + const response = await apiFetch(`/admin/traces?${params.toString()}`); + if (response.ok) { + const data = await response.json(); + setTraces(data.traces); + setTotal(data.total); + } + } catch (error) { + console.error('Failed to load traces:', error); + } finally { + setLoading(false); + } + }, [searchCorrelationId, filterStatus, filterMethod]); + + const fetchStats = useCallback(async () => { + try { + const response = await apiFetch('/admin/traces/stats'); + if (response.ok) { + setStats(await response.json()); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + void fetchTraces(); + void fetchStats(); + const interval = setInterval(fetchTraces, 10_000); + return () => clearInterval(interval); + }, [fetchTraces, fetchStats]); + + const statusBadgeVariant = (status: string) => { + switch (status) { + case 'success': return 'default'; + case 'redirect': return 'secondary'; + case 'client_error': return 'warning'; + case 'server_error': return 'destructive'; + default: return 'outline'; + } + }; + + const methodColor = (method: string) => { + switch (method) { + case 'GET': return 'text-green-500'; + case 'POST': return 'text-blue-500'; + case 'PUT': return 'text-orange-500'; + case 'PATCH': return 'text-purple-500'; + case 'DELETE': return 'text-red-500'; + default: return ''; + } + }; + + return ( +
+
+

+ + API Request Trace Viewer +

+

+ Inspect API requests with correlation IDs, timing, and error tracking +

+
+ + {stats && ( +
+ + + Total Traces + + +
{stats.total}
+

{stats.lastMinuteCount} in last minute

+
+
+ + + Avg Duration + + +
{stats.averageDurationMs}ms
+

P95 {stats.p95DurationMs}ms

+
+
+ + + Error Rate + + +
{stats.errorRate}%
+

+ {stats.byStatus.client_error + stats.byStatus.server_error} errors +

+
+
+ + + GET + + +
{stats.byMethod.GET}
+
+
+ + + POST + + +
{stats.byMethod.POST}
+
+
+
+ )} + + + + + + Trace Explorer + + + Showing {traces.length} of {total} traces + + + +
+
+ setSearchCorrelationId(e.target.value)} + className="w-64" + /> +
+ + + +
+ +
+ + + + + Correlation ID + Method + Path + Status + Duration + Time + + + + {loading && traces.length === 0 ? ( + + + Loading traces... + + + ) : traces.length === 0 ? ( + + + No traces found + + + ) : ( + traces.map((trace) => ( + <> + setExpandedId(expandedId === trace.id ? null : trace.id)} + > + + {expandedId === trace.id + ? + : } + + + + {trace.correlationId} + + + + + {trace.method} + + + + {trace.path} + + + + {trace.statusCode} + + + +
+ + {trace.durationMs}ms +
+
+ + {new Date(trace.timestamp).toLocaleTimeString()} + +
+ {expandedId === trace.id && ( + + +
+
+

+ + Request Details +

+
+

URL: {trace.path}

+

Method: {trace.method}

+ {trace.userId && ( +

User ID: {trace.userId}

+ )} + {trace.ip && ( +

IP: {trace.ip}

+ )} + {trace.userAgent && ( +

User Agent: {trace.userAgent}

+ )} +
+ {trace.errorMessage && ( +
+
+ + Error +
+ {trace.errorMessage} +
+ )} +
+
+

+ + Timing & Status +

+
+

Duration: {trace.durationMs}ms

+

Status: {trace.statusCode} ({trace.status})

+

Timestamp: {new Date(trace.timestamp).toLocaleString()}

+

Correlation ID: {trace.correlationId}

+
+
+ + {trace.statusCode < 300 ? ( + + ) : ( + + )} + {trace.durationMs < 1000 ? 'Fast' : 'Slow'} + +
+
+
+
+
+ )} + + )) + )} +
+
+
+
+
+
+ ); +} diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index eb14b8d..100d49d 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -1,4 +1,4 @@ -import { Shield, AlertTriangle, Activity, BarChart3, Server, FileWarning, Bell, Gauge, FileText, TrendingUp, Route } from "lucide-react"; +import { Shield, AlertTriangle, Activity, BarChart3, Server, FileWarning, Bell, Gauge, FileText, TrendingUp, Route, Search, UserCheck, DollarSign } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Link } from "react-router-dom"; @@ -73,6 +73,27 @@ const adminCards = [ href: "/admin/reports", color: "text-green-500", }, + { + title: "API Trace Viewer", + description: "Inspect API request traces, correlation IDs, and errors", + icon: Search, + href: "/admin/traces", + color: "text-cyan-500", + }, + { + title: "Recipient Verification", + description: "Manage recipient badges, verification, and trust scores", + icon: UserCheck, + href: "/admin/verifications", + color: "text-pink-500", + }, + { + title: "Threshold Rules", + description: "Configure approval thresholds and rule management", + icon: Shield, + href: "/admin/thresholds", + color: "text-amber-500", + }, ]; export default function AdminDashboard() { diff --git a/src/pages/AdminThresholdRules.tsx b/src/pages/AdminThresholdRules.tsx new file mode 100644 index 0000000..23935ee --- /dev/null +++ b/src/pages/AdminThresholdRules.tsx @@ -0,0 +1,407 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Shield, Plus, Pencil, Trash2, ToggleLeft, ToggleRight, GripVertical } from 'lucide-react'; + +import { apiFetch } from '@/lib/api'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; + +interface ThresholdRule { + id: string; + name: string; + description: string; + enabled: boolean; + priority: number; + condition: string; + config: Record; + action: string; + approvalLevel: number; + notifyAdmins: boolean; + createdAt: string; + updatedAt: string; + createdBy?: string; +} + +interface ApprovalLevel { + level: number; + name: string; + description: string; + requiredApprovers: number; + approverRoles: string[]; +} + +const defaultFormData = { + name: '', + description: '', + condition: 'amount_above', + config: '{}', + action: 'require_approval', + approvalLevel: 1, + priority: 50, + notifyAdmins: true, +}; + +export default function AdminThresholdRules() { + const [rules, setRules] = useState([]); + const [levels, setLevels] = useState([]); + const [loading, setLoading] = useState(false); + const [showDialog, setShowDialog] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState(defaultFormData); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [rulesRes, levelsRes] = await Promise.all([ + apiFetch('/admin/thresholds'), + apiFetch('/admin/thresholds/levels'), + ]); + if (rulesRes.ok) setRules(await rulesRes.json()); + if (levelsRes.ok) setLevels(await levelsRes.json()); + } catch (error) { + console.error('Failed to load thresholds:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + const openCreate = () => { + setEditingId(null); + setFormData(defaultFormData); + setShowDialog(true); + }; + + const openEdit = (rule: ThresholdRule) => { + setEditingId(rule.id); + setFormData({ + name: rule.name, + description: rule.description, + condition: rule.condition, + config: JSON.stringify(rule.config, null, 2), + action: rule.action, + approvalLevel: rule.approvalLevel, + priority: rule.priority, + notifyAdmins: rule.notifyAdmins, + }); + setShowDialog(true); + }; + + const handleSave = async () => { + const payload = { + name: formData.name, + description: formData.description, + condition: formData.condition, + config: JSON.parse(formData.config || '{}'), + action: formData.action, + approvalLevel: formData.approvalLevel, + priority: formData.priority, + notifyAdmins: formData.notifyAdmins, + }; + + const url = editingId + ? `/admin/thresholds/${editingId}` + : '/admin/thresholds'; + const method = editingId ? 'PUT' : 'POST'; + + const response = await apiFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + setShowDialog(false); + void fetchData(); + } + }; + + const handleDelete = async (ruleId: string) => { + const response = await apiFetch(`/admin/thresholds/${ruleId}`, { method: 'DELETE' }); + if (response.ok) { + void fetchData(); + } + }; + + const handleToggle = async (ruleId: string) => { + const response = await apiFetch(`/admin/thresholds/${ruleId}/toggle`, { method: 'POST' }); + if (response.ok) { + void fetchData(); + } + }; + + const actionBadgeVariant = (action: string) => { + switch (action) { + case 'allow': return 'default'; + case 'require_approval': return 'secondary'; + case 'require_second_approval': return 'warning'; + case 'require_compliance_review': return 'destructive'; + case 'block': return 'destructive'; + default: return 'outline'; + } + }; + + const actionLabel = (action: string) => { + switch (action) { + case 'allow': return 'Allow'; + case 'require_approval': return 'Approval'; + case 'require_second_approval': return '2nd Approval'; + case 'require_compliance_review': return 'Compliance'; + case 'block': return 'Block'; + default: return action; + } + }; + + return ( +
+
+
+

+ + Approval Threshold Rules +

+

+ Configure transfer approval thresholds, multi-level approvals, and rule management +

+
+ +
+ + {levels.length > 0 && ( +
+ {levels.map((level) => ( + + + Level {level.level} + {level.name} + + +

{level.description}

+

+ {level.requiredApprovers} approver(s) needed · {level.approverRoles.join(', ')} +

+
+
+ ))} +
+ )} + + + + Threshold Rules + + {rules.length} rule{rules.length !== 1 ? 's' : ''} configured · {rules.filter((r) => r.enabled).length} active + + + + {loading && rules.length === 0 ? ( +
Loading...
+ ) : rules.length === 0 ? ( +
+ No rules configured yet +
+ ) : ( +
+ {rules.map((rule) => ( +
+
+
+
+

{rule.name}

+ + {actionLabel(rule.action)} + + {!rule.enabled && ( + Disabled + )} +
+

{rule.description}

+
+ Condition: {rule.condition} + Priority: {rule.priority} + {rule.approvalLevel > 0 && ( + Approval Level: {rule.approvalLevel} + )} + {rule.notifyAdmins && Notifies admins} +
+
+ + {JSON.stringify(rule.config)} + +
+
+
+ + + +
+
+
+ ))} +
+ )} +
+
+ + + + + {editingId ? 'Edit Rule' : 'Create Rule'} + + Configure the threshold rule conditions and action + + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Large Transfer" + /> +
+ +
+ +