diff --git a/apps/api/migrations/008_data_retention_case_management_portfolio_forecast.sql b/apps/api/migrations/008_data_retention_case_management_portfolio_forecast.sql new file mode 100644 index 0000000..e576995 --- /dev/null +++ b/apps/api/migrations/008_data_retention_case_management_portfolio_forecast.sql @@ -0,0 +1,35 @@ +-- Migration 008: Data retention policies, case management queue, portfolio view, and tariff forecasting + +-- #1031: Data retention policies per data category +CREATE TABLE IF NOT EXISTS data_retention_policies ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE, + data_category TEXT NOT NULL CHECK (data_category IN ('documents', 'logs', 'events', 'tariff_uploads')), + retention_days INTEGER NOT NULL CHECK (retention_days > 0), + is_regulatory_required BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (importer_id, data_category) +); + +CREATE INDEX IF NOT EXISTS idx_data_retention_policies_importer ON data_retention_policies(importer_id); + +-- #1029: Case management queue for compliance flags +ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS assigned_to UUID REFERENCES users(id); +ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS priority TEXT DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical')); +ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS case_status TEXT DEFAULT 'new' CHECK (case_status IN ('new', 'investigating', 'escalated', 'resolved')); + +-- Case notes table +CREATE TABLE IF NOT EXISTS compliance_case_notes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + flag_id UUID NOT NULL REFERENCES compliance_flags(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_compliance_case_notes_flag ON compliance_case_notes(flag_id, created_at DESC); + +-- Index for queue filtering +CREATE INDEX IF NOT EXISTS idx_compliance_flags_assignment ON compliance_flags(assigned_to, case_status, priority); +CREATE INDEX IF NOT EXISTS idx_compliance_flags_status ON compliance_flags(case_status, priority); \ No newline at end of file diff --git a/apps/api/src/routes/compliance.ts b/apps/api/src/routes/compliance.ts index d3722fd..73513e5 100644 --- a/apps/api/src/routes/compliance.ts +++ b/apps/api/src/routes/compliance.ts @@ -181,6 +181,9 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => { resolution_status: z.enum(['open', 'resolved']).optional(), severity: z.enum(['low', 'medium', 'high', 'critical']).optional(), importer_id: z.string().uuid().optional(), + assigned_to: z.string().uuid().optional(), + case_status: z.enum(['new', 'investigating', 'escalated', 'resolved']).optional(), + priority: z.enum(['low', 'medium', 'high', 'critical']).optional(), limit: z.coerce.number().int().positive().max(100).default(50), offset: z.coerce.number().int().min(0).default(0), }) @@ -191,7 +194,7 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => { return; } - const { resolution_status, severity, importer_id, limit, offset } = query.data; + const { resolution_status, severity, importer_id, assigned_to, case_status, priority, limit, offset } = query.data; const conditions: string[] = ['cf.surety_id = $1']; const params: unknown[] = [user.id]; let idx = 2; @@ -208,6 +211,18 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => { conditions.push(`cf.importer_id = $${idx++}`); params.push(importer_id); } + if (assigned_to) { + conditions.push(`cf.assigned_to = $${idx++}`); + params.push(assigned_to); + } + if (case_status) { + conditions.push(`cf.case_status = $${idx++}`); + params.push(case_status); + } + if (priority) { + conditions.push(`cf.priority = $${idx++}`); + params.push(priority); + } const where = conditions.join(' AND '); @@ -215,11 +230,15 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => { pool.query( `SELECT cf.id, cf.importer_id, i.legal_name AS importer_name, cf.flag_type, cf.severity, cf.description, - cf.resolution_status, cf.resolution_note, cf.resolved_at, cf.created_at + cf.resolution_status, cf.resolution_note, cf.resolved_at, cf.created_at, + cf.assigned_to, cf.priority, cf.case_status, + EXTRACT(EPOCH FROM (now() - cf.created_at)) / 3600 AS age_hours FROM compliance_flags cf JOIN importers i ON i.id = cf.importer_id WHERE ${where} - ORDER BY cf.created_at DESC + ORDER BY + CASE cf.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, + cf.created_at DESC LIMIT $${idx} OFFSET $${idx + 1}`, [...params, limit, offset] ), @@ -229,8 +248,15 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => { ), ]); + // SLA indicator: flag cases open beyond 72 hours (3 days) + const slaThresholdHours = 72; + const flagsWithSla = flags.rows.map((flag) => ({ + ...flag, + slaBreached: Number(flag.age_hours) > slaThresholdHours, + })); + res.json({ - flags: flags.rows, + flags: flagsWithSla, total: parseInt(total.rows[0]?.cnt ?? '0', 10), limit, offset, @@ -270,6 +296,128 @@ complianceRouter.post('/flags/:id/resolve', async (req: Request, res: Response) res.json({ success: true }); }); +// POST /api/v1/compliance/flags/:id/assign — assign a flag to an admin user +complianceRouter.post('/flags/:id/assign', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z.object({ assigned_to: z.string().uuid() }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'assigned_to is required' }); + return; + } + + const flag = await pool.query( + `SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`, + [req.params.id, user.id] + ); + if (!flag.rowCount) { + res.status(404).json({ error: 'flag not found' }); + return; + } + + await pool.query( + `UPDATE compliance_flags + SET assigned_to = $1, case_status = 'investigating', updated_at = now() + WHERE id = $2`, + [parse.data.assigned_to, req.params.id] + ); + + res.json({ success: true }); +}); + +// POST /api/v1/compliance/flags/:id/status — update case status +complianceRouter.post('/flags/:id/status', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z.object({ + case_status: z.enum(['new', 'investigating', 'escalated', 'resolved']), + priority: z.enum(['low', 'medium', 'high', 'critical']).optional(), + }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input' }); + return; + } + + const flag = await pool.query( + `SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`, + [req.params.id, user.id] + ); + if (!flag.rowCount) { + res.status(404).json({ error: 'flag not found' }); + return; + } + + const updates = ['case_status = $1', 'updated_at = now()']; + const params: unknown[] = [parse.data.case_status]; + let idx = 2; + + if (parse.data.priority) { + updates.push(`priority = $${idx++}`); + params.push(parse.data.priority); + } + + params.push(req.params.id); + await pool.query( + `UPDATE compliance_flags SET ${updates.join(', ')} WHERE id = $${idx}`, + params + ); + + res.json({ success: true }); +}); + +// POST /api/v1/compliance/flags/:id/notes — add a case note +complianceRouter.post('/flags/:id/notes', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z.object({ content: z.string().min(1) }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'content is required' }); + return; + } + + const flag = await pool.query( + `SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`, + [req.params.id, user.id] + ); + if (!flag.rowCount) { + res.status(404).json({ error: 'flag not found' }); + return; + } + + const result = await pool.query( + `INSERT INTO compliance_case_notes (flag_id, author_id, content) + VALUES ($1, $2, $3) + RETURNING id, flag_id, author_id, content, created_at`, + [req.params.id, user.id, parse.data.content] + ); + + res.status(201).json({ note: result.rows[0] }); +}); + +// GET /api/v1/compliance/flags/:id/notes — list case notes +complianceRouter.get('/flags/:id/notes', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const flag = await pool.query( + `SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`, + [req.params.id, user.id] + ); + if (!flag.rowCount) { + res.status(404).json({ error: 'flag not found' }); + return; + } + + const result = await pool.query( + `SELECT id, flag_id, author_id, content, created_at + FROM compliance_case_notes + WHERE flag_id = $1 + ORDER BY created_at DESC`, + [req.params.id] + ); + + res.json({ notes: result.rows }); +}); + // GET /api/v1/compliance/reports — list available compliance reports for this surety complianceRouter.get('/reports', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; diff --git a/apps/api/src/routes/erasure.ts b/apps/api/src/routes/erasure.ts index f739218..8d96e31 100644 --- a/apps/api/src/routes/erasure.ts +++ b/apps/api/src/routes/erasure.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; -import { pool, createDataErasureRequest } from '../db.js'; +import { pool, createDataErasureRequest, logAudit } from '../db.js'; import { authMiddleware, privacyReacceptanceGate, @@ -79,3 +79,97 @@ erasureRouter.get('/account/erasure-request/:requestId', async (req: Request, re errorMessage: request.error_message, }); }); + +// ── #1031: Data retention policy configuration ────────────────────────────── + +const RetentionPolicySchema = z.object({ + dataCategory: z.enum(['documents', 'logs', 'events', 'tariff_uploads']), + retentionDays: z.number().int().positive(), +}); + +// GET /api/v1/erasure/retention-policies — list retention policies for the importer +erasureRouter.get('/retention-policies', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]); + const importerId = importerResult.rows[0]?.id ?? null; + + if (!importerId) { + res.status(404).json({ error: 'importer not found' }); + return; + } + + const result = await pool.query( + 'SELECT id, data_category, retention_days, is_regulatory_required, created_at, updated_at FROM data_retention_policies WHERE importer_id = $1 ORDER BY data_category', + [importerId] + ); + + res.json({ policies: result.rows }); +}); + +// POST /api/v1/erasure/retention-policies — set retention policy for a data category +erasureRouter.post('/retention-policies', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = RetentionPolicySchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + + const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]); + const importerId = importerResult.rows[0]?.id ?? null; + + if (!importerId) { + res.status(404).json({ error: 'importer not found' }); + return; + } + + const { dataCategory, retentionDays } = parse.data; + + // Regulatory-required categories are excluded from configurable retention + const regulatoryCategories = ['documents']; // KYC documents have regulatory retention + const isRegulatoryRequired = regulatoryCategories.includes(dataCategory); + + const result = await pool.query( + `INSERT INTO data_retention_policies (importer_id, data_category, retention_days, is_regulatory_required) + VALUES ($1, $2, $3, $4) + ON CONFLICT (importer_id, data_category) + DO UPDATE SET retention_days = $3, updated_at = now() + RETURNING id, data_category, retention_days, is_regulatory_required, created_at, updated_at`, + [importerId, dataCategory, retentionDays, isRegulatoryRequired] + ); + + await logAudit(user.id, 'set_retention_policy', importerId, { + dataCategory, + retentionDays, + isRegulatoryRequired, + }); + + res.json({ policy: result.rows[0] }); +}); + +// DELETE /api/v1/erasure/retention-policies/:id — delete a retention policy +erasureRouter.delete('/retention-policies/:id', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]); + const importerId = importerResult.rows[0]?.id ?? null; + + if (!importerId) { + res.status(404).json({ error: 'importer not found' }); + return; + } + + const result = await pool.query( + 'DELETE FROM data_retention_policies WHERE id = $1 AND importer_id = $2 AND is_regulatory_required = FALSE', + [req.params.id, importerId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: 'policy not found or is regulatory-required' }); + return; + } + + res.json({ success: true }); +}); diff --git a/apps/api/src/routes/importers.ts b/apps/api/src/routes/importers.ts index 0251480..3c7baa5 100644 --- a/apps/api/src/routes/importers.ts +++ b/apps/api/src/routes/importers.ts @@ -1250,6 +1250,195 @@ importersRouter.get('/:id/documents', async (req: Request, res: Response) => { res.json({ documents }); }); +// ── #1028: Multi-bond portfolio view ────────────────────────────────────── + +importersRouter.get('/:id/portfolio', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + // Get all bonds for this importer + const bondsResult = await pool.query( + `SELECT id, bond_number, policy_type, coverage_amount, status, + issued_at, expires_at, replaced_by_id, stellar_contract_address, created_at + FROM bonds WHERE importer_id = $1 ORDER BY created_at DESC`, + [importer.id] + ); + + const bonds = bondsResult.rows; + + // Aggregate totals + const totalCoverage = bonds.reduce((sum, bond) => sum + Number(bond.coverage_amount || 0), 0); + const activeBonds = bonds.filter(bond => bond.status === 'active'); + const totalActiveCoverage = activeBonds.reduce((sum, bond) => sum + Number(bond.coverage_amount || 0), 0); + + // Upcoming renewals (within 90 days) + const now = new Date(); + const ninetyDaysFromNow = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); + const upcomingRenewals = bonds.filter(bond => { + if (!bond.expires_at) return false; + const expiresAt = new Date(bond.expires_at); + return expiresAt >= now && expiresAt <= ninetyDaysFromNow; + }); + + // Get collateral status from on-chain + let collateralStatus; + try { + const acct = await contractClient.getAccount(importer.stellar_address); + collateralStatus = { + collateralBalance: acct.collateralBalance.toString(), + requiredCollateral: acct.requiredCollateral.toString(), + reserveBalance: acct.reserveBalance.toString(), + yieldAccrued: acct.yieldAccrued.toString(), + }; + } catch (err) { + collateralStatus = null; + } + + // Sort options + const sortBy = String(req.query.sort_by || 'created_at'); + const sortOrder = String(req.query.sort_order || 'desc'); + + let sortedBonds = [...bonds]; + if (sortBy === 'expires_at') { + sortedBonds.sort((a, b) => { + const dateA = a.expires_at ? new Date(a.expires_at).getTime() : 0; + const dateB = b.expires_at ? new Date(b.expires_at).getTime() : 0; + return sortOrder === 'asc' ? dateA - dateB : dateB - dateA; + }); + } else if (sortBy === 'coverage_amount') { + sortedBonds.sort((a, b) => { + const amountA = Number(a.coverage_amount || 0); + const amountB = Number(b.coverage_amount || 0); + return sortOrder === 'asc' ? amountA - amountB : amountB - amountA; + }); + } else if (sortBy === 'status') { + sortedBonds.sort((a, b) => { + const statusOrder = { active: 0, pending: 1, expired: 2, replaced: 3 }; + const orderA = statusOrder[a.status as keyof typeof statusOrder] ?? 4; + const orderB = statusOrder[b.status as keyof typeof statusOrder] ?? 4; + return sortOrder === 'asc' ? orderA - orderB : orderB - orderA; + }); + } + + // Filter by status if provided + const statusFilter = String(req.query.status || ''); + if (statusFilter) { + sortedBonds = sortedBonds.filter(bond => bond.status === statusFilter); + } + + res.json({ + importer: { + id: importer.id, + legalName: importer.legal_name, + bondId: importer.bond_id, + }, + portfolio: { + totalBonds: bonds.length, + activeBonds: activeBonds.length, + totalCoverage, + totalActiveCoverage, + upcomingRenewalsCount: upcomingRenewals.length, + upcomingRenewals: upcomingRenewals.map(bond => ({ + id: bond.id, + bondNumber: bond.bond_number, + expiresAt: bond.expires_at, + coverageAmount: bond.coverage_amount, + })), + }, + collateralStatus, + bonds: sortedBonds, + }); +}); + +// ── #1030: Tariff exposure forecasting ──────────────────────────────────── + +importersRouter.get('/:id/forecast', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + // Get historical tariff uploads + const uploadsResult = await pool.query( + `SELECT id, annual_duty_total, computed_required_collateral, created_at + FROM tariff_uploads + WHERE importer_id = $1 + ORDER BY created_at ASC`, + [importer.id] + ); + + const uploads = uploadsResult.rows; + + if (uploads.length < 2) { + res.json({ + forecast: null, + message: 'Insufficient upload history for forecasting (need at least 2 uploads)', + historicalData: uploads.map(u => ({ + date: u.created_at, + annualDutyTotal: u.annual_duty_total, + requiredCollateral: u.computed_required_collateral, + })), + }); + return; + } + + // Calculate linear trend + const n = uploads.length; + const xValues = uploads.map((_, i) => i); + const yValues = uploads.map(u => Number(u.annual_duty_total)); + + const sumX = xValues.reduce((a, b) => a + b, 0); + const sumY = yValues.reduce((a, b) => a + b, 0); + const sumXY = xValues.reduce((sum, x, i) => sum + x * (yValues[i] ?? 0), 0); + const sumX2 = xValues.reduce((sum, x) => sum + x * x, 0); + + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + // Project 30, 60, 90 days out (assuming monthly uploads) + const lastX = n - 1; + const firstUpload = uploads[0]; + const lastUpload = uploads[n - 1]; + const monthsPerUpload = uploads.length > 1 && firstUpload && lastUpload ? + (new Date(lastUpload.created_at).getTime() - new Date(firstUpload.created_at).getTime()) / (n - 1) / (30 * 24 * 60 * 60 * 1000) : 1; + + const forecastPoints = [30, 60, 90].map(days => { + const monthsOut = days / 30; + const futureX = lastX + monthsOut / monthsPerUpload; + const projectedDuty = slope * futureX + intercept; + const projectedCollateral = projectedDuty * 0.1 * 0.5; // Same formula as upload handler + return { + daysOut: days, + projectedAnnualDutyTotal: Math.max(0, projectedDuty), + projectedRequiredCollateral: Math.max(0, projectedCollateral), + isProjection: true, + }; + }); + + // Historical data for chart + const historicalData = uploads.map(u => ({ + date: u.created_at, + annualDutyTotal: Number(u.annual_duty_total), + requiredCollateral: Number(u.computed_required_collateral), + isProjection: false, + })); + + res.json({ + forecast: { + trend: slope > 0 ? 'increasing' : slope < 0 ? 'decreasing' : 'stable', + slope, + intercept, + projections: forecastPoints, + }, + historicalData, + disclaimer: 'This forecast is a non-binding projection based on historical trends and should not be used as financial advice.', + }); +}); + // DELETE /importers/:id/documents/:docId — surety_admin only importersRouter.delete('/:id/documents/:docId', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user;