diff --git a/apps/api/src/__tests__/integration/features-1038-1039-1040-1041.test.ts b/apps/api/src/__tests__/integration/features-1038-1039-1040-1041.test.ts new file mode 100644 index 0000000..45f1777 --- /dev/null +++ b/apps/api/src/__tests__/integration/features-1038-1039-1040-1041.test.ts @@ -0,0 +1,300 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { up as migrationUp } from '../../migrations/0009_dual_approvals_sku_mappings_document_expiry.js'; + +const DATABASE_URL = + process.env.DATABASE_URL ?? 'postgres://test:test@localhost:5432/tariffshield_test'; + +const pool = new Pool({ connectionString: DATABASE_URL }); + +const testTag = randomUUID().slice(0, 8); +const importerEmail = `test-imp-${testTag}@example.com`; +const approverEmail = `test-appr-${testTag}@example.com`; +const adminEmail = `test-admin-${testTag}@example.com`; + +let importerUserId: string; +let approverUserId: string; +let adminUserId: string; +let importerId: string; +const testBondId = Math.floor(Math.random() * 9_000_000) + 1_000_000; + +describe('Issues #1038, #1039, #1040, #1041 integration test suite', () => { + before(async () => { + // Ensure migration 0009 has run + const client = await pool.connect(); + try { + await migrationUp(client); + } finally { + client.release(); + } + + // Seed users + const u1 = await pool.query<{ id: string }>( + 'INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id', + [importerEmail, 'hash1', 'importer'] + ); + importerUserId = u1.rows[0]!.id; + + const u2 = await pool.query<{ id: string }>( + 'INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id', + [approverEmail, 'hash2', 'importer'] + ); + approverUserId = u2.rows[0]!.id; + + const u3 = await pool.query<{ id: string }>( + 'INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id', + [adminEmail, 'hash3', 'surety_admin'] + ); + adminUserId = u3.rows[0]!.id; + + // Seed importer with KYC approved + const impRes = await pool.query<{ id: string }>( + `INSERT INTO importers (user_id, legal_name, bond_id, stellar_address, kyc_status, collateral_balance) + VALUES ($1, $2, $3, $4, 'approved', 50000000) + RETURNING id`, + [importerUserId, 'Dual Signoff Import Corp', testBondId, 'GBXYZTEST1234567890'] + ); + importerId = impRes.rows[0]!.id; + }); + + after(async () => { + if (importerId) { + await pool.query('DELETE FROM withdrawal_requests WHERE importer_id = $1', [importerId]); + await pool.query('DELETE FROM importer_sku_mappings WHERE importer_id = $1', [importerId]); + await pool.query('DELETE FROM kyc_documents WHERE importer_id = $1', [importerId]); + await pool.query('DELETE FROM audit_log WHERE actor_user_id IN ($1, $2, $3)', [ + importerUserId, + approverUserId, + adminUserId, + ]); + await pool.query('DELETE FROM importers WHERE id = $1', [importerId]); + } + await pool.query('DELETE FROM users WHERE id IN ($1, $2, $3)', [ + importerUserId, + approverUserId, + adminUserId, + ]); + await pool.end(); + }); + + // ── Issue #1038: Dual Sign-off Approvals ────────────────────────────────── + describe('Issue #1038 — Dual Sign-off Approvals for Large Withdrawals', () => { + it('enables dual sign-off with threshold and second approver', async () => { + await pool.query( + `UPDATE importers + SET dual_approval_enabled = true, + dual_approval_threshold_stroops = 10000000, + second_approver_id = $1, + second_approver_email = $2 + WHERE id = $3`, + [approverUserId, approverEmail, importerId] + ); + + const r = await pool.query( + 'SELECT dual_approval_enabled, dual_approval_threshold_stroops, second_approver_id FROM importers WHERE id = $1', + [importerId] + ); + assert.equal(r.rows[0]?.dual_approval_enabled, true); + assert.equal(r.rows[0]?.dual_approval_threshold_stroops, '10000000'); + assert.equal(r.rows[0]?.second_approver_id, approverUserId); + }); + + it('creates a pending withdrawal request for amounts >= threshold', async () => { + const ins = await pool.query<{ id: string; status: string }>( + `INSERT INTO withdrawal_requests (importer_id, requested_by, amount_stroops, status, second_approver_id) + VALUES ($1, $2, 15000000, 'pending', $3) + RETURNING id, status`, + [importerId, importerUserId, approverUserId] + ); + assert.equal(ins.rows[0]?.status, 'pending'); + const reqId = ins.rows[0]!.id; + + // Requester cancels the request + await pool.query( + `UPDATE withdrawal_requests SET status = 'cancelled', resolved_at = now() WHERE id = $1`, + [reqId] + ); + const cancelled = await pool.query('SELECT status FROM withdrawal_requests WHERE id = $1', [ + reqId, + ]); + assert.equal(cancelled.rows[0]?.status, 'cancelled'); + }); + + it('allows designated second approver to approve pending withdrawal', async () => { + const ins = await pool.query<{ id: string; status: string }>( + `INSERT INTO withdrawal_requests (importer_id, requested_by, amount_stroops, status, second_approver_id) + VALUES ($1, $2, 20000000, 'pending', $3) + RETURNING id, status`, + [importerId, importerUserId, approverUserId] + ); + const reqId = ins.rows[0]!.id; + + // Approver confirms + await pool.query( + `UPDATE withdrawal_requests + SET status = 'approved', approved_by = $1, job_id = 'job-withdraw-123', resolved_at = now() + WHERE id = $2`, + [approverUserId, reqId] + ); + + const approved = await pool.query( + 'SELECT status, approved_by, job_id FROM withdrawal_requests WHERE id = $1', + [reqId] + ); + assert.equal(approved.rows[0]?.status, 'approved'); + assert.equal(approved.rows[0]?.approved_by, approverUserId); + assert.equal(approved.rows[0]?.job_id, 'job-withdraw-123'); + }); + }); + + // ── Issue #1039: Audit Log Search & Filter UI Backend ───────────────────── + describe('Issue #1039 — Audit Log Search and Filter', () => { + before(async () => { + // Insert test audit entries + await pool.query( + `INSERT INTO audit_log (actor_user_id, action, target_id, payload, created_at) + VALUES ($1, 'withdraw_approved', $2, '{"amountStroops":"20000000","jobId":"job-123"}'::jsonb, now() - interval '2 hours'), + ($1, 'dual_approval_configured', $2, '{"enabled":true,"thresholdStroops":"10000000"}'::jsonb, now() - interval '1 hour'), + ($3, 'kyc_status_update', $2, '{"kycStatus":"approved"}'::jsonb, now())`, + [importerUserId, importerId, adminUserId] + ); + }); + + it('filters audit logs by action type', async () => { + const r = await pool.query( + `SELECT al.*, u.email AS actor_email + FROM audit_log al + LEFT JOIN users u ON u.id = al.actor_user_id + WHERE al.action = 'dual_approval_configured'` + ); + assert.ok(r.rows.length >= 1); + assert.equal(r.rows[0]?.action, 'dual_approval_configured'); + }); + + it('searches audit log free-text across payload and description', async () => { + const searchParam = '%20000000%'; + const r = await pool.query( + `SELECT al.*, u.email AS actor_email + FROM audit_log al + LEFT JOIN users u ON u.id = al.actor_user_id + WHERE (al.action ILIKE $1 OR al.payload::text ILIKE $1 OR u.email ILIKE $1 OR al.target_id::text ILIKE $1)`, + [searchParam] + ); + assert.ok(r.rows.length >= 1); + assert.equal(r.rows[0]?.action, 'withdraw_approved'); + }); + }); + + // ── Issue #1040: Bulk HS Code Mapping Table ────────────────────────────── + describe('Issue #1040 — Bulk HS Code Mapping Table for Catalogs', () => { + it('stores versioned product SKU to HTS mappings', async () => { + // Version 1 upload + await pool.query( + `INSERT INTO importer_sku_mappings (importer_id, version, sku, hts_code, description, duty_rate, is_active) + VALUES ($1, 1, 'SKU-SHIRT-01', '6109.10.00', 'Cotton T-Shirt', 0.165, true), + ($1, 1, 'SKU-PANTS-02', '6203.42.40', 'Denim Jeans', 0.166, true)`, + [importerId] + ); + + const v1 = await pool.query( + 'SELECT sku, hts_code, is_active FROM importer_sku_mappings WHERE importer_id = $1 AND version = 1', + [importerId] + ); + assert.equal(v1.rows.length, 2); + + // Version 2 re-upload supersedes version 1 + await pool.query( + 'UPDATE importer_sku_mappings SET is_active = false WHERE importer_id = $1', + [importerId] + ); + await pool.query( + `INSERT INTO importer_sku_mappings (importer_id, version, sku, hts_code, description, duty_rate, is_active) + VALUES ($1, 2, 'SKU-SHIRT-01', '6109.10.00', 'Cotton T-Shirt v2', 0.165, true), + ($1, 2, 'SKU-HAT-03', '6505.00.80', 'Wool Hat', 0.080, true)`, + [importerId] + ); + + const active = await pool.query( + 'SELECT sku, hts_code FROM importer_sku_mappings WHERE importer_id = $1 AND is_active = true ORDER BY sku ASC', + [importerId] + ); + assert.equal(active.rows.length, 2); + assert.equal(active.rows[0]?.sku, 'SKU-HAT-03'); + assert.equal(active.rows[1]?.sku, 'SKU-SHIRT-01'); + }); + + it('flags unmapped SKUs when resolving tariff line items', async () => { + const activeMappings = await pool.query<{ sku: string; hts_code: string }>( + 'SELECT sku, hts_code FROM importer_sku_mappings WHERE importer_id = $1 AND is_active = true', + [importerId] + ); + const skuMap = new Map(activeMappings.rows.map((r) => [r.sku, r.hts_code])); + + const inputLineItems = [ + { sku: 'SKU-SHIRT-01', value: 1000 }, + { sku: 'SKU-UNMAPPED-99', value: 2000 }, + ]; + + const unmapped: string[] = []; + const resolved = []; + + for (const item of inputLineItems) { + const hts = skuMap.get(item.sku); + if (hts) { + resolved.push({ sku: item.sku, htsCode: hts, value: item.value }); + } else { + unmapped.push(item.sku); + } + } + + assert.equal(resolved.length, 1); + assert.equal(unmapped.length, 1); + assert.equal(unmapped[0], 'SKU-UNMAPPED-99'); + }); + }); + + // ── Issue #1041: Document Expiration Calendar View ──────────────────────── + describe('Issue #1041 — Consolidated Document Expiration Calendar', () => { + before(async () => { + // Insert KYC document with expiry in 20 days (critical) + const expiryDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000); + await pool.query( + `INSERT INTO kyc_documents (importer_id, document_type, document_name, s3_key_encrypted, expiration_date) + VALUES ($1, 'articles_of_incorporation', 'Corporate Articles', 'key-enc-1', $2)`, + [importerId, expiryDate] + ); + + // Insert surety state license with expiry in 45 days (warning) + const licExpiry = new Date(Date.now() + 45 * 24 * 60 * 60 * 1000); + await pool.query( + `INSERT INTO surety_state_licenses (state_code, license_number, expiration_date, status) + VALUES ('CA', 'LIC-CA-9988', $1, 'active') + ON CONFLICT (state_code) DO UPDATE SET expiration_date = EXCLUDED.expiration_date`, + [licExpiry] + ); + }); + + it('aggregates KYC and license expirations with accurate urgency thresholds', async () => { + const kycRes = await pool.query( + 'SELECT id, document_type, expiration_date FROM kyc_documents WHERE importer_id = $1 AND deleted_at IS NULL', + [importerId] + ); + assert.ok(kycRes.rows.length >= 1); + + const expiry = new Date(kycRes.rows[0]!.expiration_date); + const daysUntil = Math.ceil((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + assert.ok(daysUntil <= 30); // Critical threshold <= 30d + + const licRes = await pool.query( + "SELECT state_code, license_number, expiration_date FROM surety_state_licenses WHERE state_code = 'CA'" + ); + assert.ok(licRes.rows.length >= 1); + const licDays = Math.ceil( + (new Date(licRes.rows[0]!.expiration_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + assert.ok(licDays > 30 && licDays <= 60); // Warning threshold 31-60d + }); + }); +}); diff --git a/apps/api/src/migrations/0006_stakeholder_subscriptions_annotations_sla.ts b/apps/api/src/migrations/0007_stakeholder_subscriptions_annotations_sla.ts similarity index 100% rename from apps/api/src/migrations/0006_stakeholder_subscriptions_annotations_sla.ts rename to apps/api/src/migrations/0007_stakeholder_subscriptions_annotations_sla.ts diff --git a/apps/api/src/migrations/0009_dual_approvals_sku_mappings_document_expiry.ts b/apps/api/src/migrations/0009_dual_approvals_sku_mappings_document_expiry.ts new file mode 100644 index 0000000..4867d07 --- /dev/null +++ b/apps/api/src/migrations/0009_dual_approvals_sku_mappings_document_expiry.ts @@ -0,0 +1,91 @@ +import type { PoolClient } from 'pg'; + +export const up = async (client: PoolClient): Promise => { + // ── #1038: Dual Sign-Off Approval Workflow for Large Withdrawals ─────────── + await client.query(` + ALTER TABLE importers + ADD COLUMN IF NOT EXISTS dual_approval_enabled BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS dual_approval_threshold_stroops NUMERIC(20, 0) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS second_approver_id UUID REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS second_approver_email TEXT; + + CREATE TABLE IF NOT EXISTS withdrawal_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE, + requested_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_stroops NUMERIC(20, 0) NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled')), + second_approver_id UUID REFERENCES users(id) ON DELETE SET NULL, + approved_by UUID REFERENCES users(id) ON DELETE SET NULL, + rejected_by UUID REFERENCES users(id) ON DELETE SET NULL, + rejection_reason TEXT, + job_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_importer_status + ON withdrawal_requests(importer_id, status); + CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_created_at + ON withdrawal_requests(created_at DESC); + `); + + // ── #1040: Bulk HS Code Mapping Table for Product Catalogs ───────────────── + await client.query(` + CREATE TABLE IF NOT EXISTS importer_sku_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE, + version INTEGER NOT NULL DEFAULT 1, + sku TEXT NOT NULL, + hts_code TEXT NOT NULL, + description TEXT, + duty_rate NUMERIC(10, 4), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_importer_sku_version UNIQUE (importer_id, version, sku) + ); + + CREATE INDEX IF NOT EXISTS idx_sku_mappings_importer_active + ON importer_sku_mappings(importer_id, is_active); + CREATE INDEX IF NOT EXISTS idx_sku_mappings_importer_sku + ON importer_sku_mappings(importer_id, sku); + `); + + // ── #1041: Document Expiration Tracking Columns ─────────────────────────── + await client.query(` + ALTER TABLE kyc_documents + ADD COLUMN IF NOT EXISTS expiration_date TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS document_name TEXT; + + ALTER TABLE surety_state_licenses + ADD COLUMN IF NOT EXISTS expiration_date TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS renewal_url TEXT; + + CREATE INDEX IF NOT EXISTS idx_kyc_docs_expiry + ON kyc_documents(expiration_date) + WHERE expiration_date IS NOT NULL AND deleted_at IS NULL; + + CREATE INDEX IF NOT EXISTS idx_surety_licenses_expiry + ON surety_state_licenses(expiration_date) + WHERE expiration_date IS NOT NULL; + `); +}; + +export const down = async (client: PoolClient): Promise => { + await client.query(` + DROP TABLE IF EXISTS importer_sku_mappings CASCADE; + DROP TABLE IF EXISTS withdrawal_requests CASCADE; + ALTER TABLE importers + DROP COLUMN IF EXISTS dual_approval_enabled, + DROP COLUMN IF EXISTS dual_approval_threshold_stroops, + DROP COLUMN IF EXISTS second_approver_id, + DROP COLUMN IF EXISTS second_approver_email; + ALTER TABLE kyc_documents + DROP COLUMN IF EXISTS expiration_date, + DROP COLUMN IF EXISTS document_name; + ALTER TABLE surety_state_licenses + DROP COLUMN IF EXISTS expiration_date, + DROP COLUMN IF EXISTS renewal_url; + `); +}; diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index dd6dab1..cba2eb1 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -23,8 +23,11 @@ const AuditLogQuerySchema = z.object({ action: z.string().optional(), from: z.string().datetime({ offset: true }).optional(), to: z.string().datetime({ offset: true }).optional(), + search: z.string().optional(), + format: z.enum(['json', 'csv']).optional(), + export: z.enum(['json', 'csv']).optional(), page: z.coerce.number().int().min(1).default(1), - per_page: z.coerce.number().int().min(1).max(200).default(50), + per_page: z.coerce.number().int().min(1).max(1000).default(50), }); adminRouter.get('/audit-log', requireRole('surety_admin'), async (req: Request, res: Response) => { @@ -33,7 +36,18 @@ adminRouter.get('/audit-log', requireRole('surety_admin'), async (req: Request, res.status(400).json({ error: 'invalid query params', details: parse.error.issues }); return; } - const { actor_user_id, action, from, to, page, per_page } = parse.data; + const { + actor_user_id, + action, + from, + to, + search, + format, + export: exportParam, + page, + per_page, + } = parse.data; + const isCsv = format === 'csv' || exportParam === 'csv' || req.headers.accept === 'text/csv'; const offset = (page - 1) * per_page; const conditions: string[] = []; @@ -41,34 +55,94 @@ adminRouter.get('/audit-log', requireRole('surety_admin'), async (req: Request, if (actor_user_id) { params.push(actor_user_id); - conditions.push(`actor_user_id = $${params.length}`); + conditions.push(`al.actor_user_id = $${params.length}`); } if (action) { params.push(action); - conditions.push(`action = $${params.length}`); + conditions.push(`al.action = $${params.length}`); } if (from) { params.push(from); - conditions.push(`created_at >= $${params.length}`); + conditions.push(`al.created_at >= $${params.length}`); } if (to) { params.push(to); - conditions.push(`created_at <= $${params.length}`); + conditions.push(`al.created_at <= $${params.length}`); + } + if (search && search.trim().length > 0) { + params.push(`%${search.trim()}%`); + conditions.push( + `(al.action ILIKE $${params.length} OR al.payload::text ILIKE $${params.length} OR u.email ILIKE $${params.length} OR al.target_id::text ILIKE $${params.length})` + ); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + if (isCsv) { + // Export all matching rows for CSV + const csvResult = await pool.query( + `SELECT al.id, al.actor_user_id, u.email AS actor_email, al.action, al.target_id, al.payload, al.created_at + FROM audit_log al + LEFT JOIN users u ON u.id = al.actor_user_id + ${where} + ORDER BY al.created_at DESC + LIMIT 5000`, + params + ); + + const escapeCsv = (val: unknown): string => { + if (val === null || val === undefined) return ''; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + + const header = [ + 'id', + 'timestamp', + 'actor_email', + 'actor_user_id', + 'action', + 'target_id', + 'payload', + ]; + const rows = csvResult.rows.map((row) => + [ + escapeCsv(row.id), + escapeCsv((row.created_at as Date).toISOString()), + escapeCsv(row.actor_email), + escapeCsv(row.actor_user_id), + escapeCsv(row.action), + escapeCsv(row.target_id), + escapeCsv(row.payload), + ].join(',') + ); + + const csvContent = [header.join(','), ...rows].join('\r\n'); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="audit-log-export.csv"'); + res.status(200).send(csvContent); + return; + } + const countResult = await pool.query<{ count: string }>( - `SELECT COUNT(*) AS count FROM audit_log ${where}`, + `SELECT COUNT(*) AS count + FROM audit_log al + LEFT JOIN users u ON u.id = al.actor_user_id + ${where}`, params ); const total = parseInt(countResult.rows[0]?.count ?? '0', 10); params.push(per_page, offset); const dataResult = await pool.query( - `SELECT id, actor_user_id, action, target_id, payload, created_at - FROM audit_log ${where} - ORDER BY created_at DESC + `SELECT al.id, al.actor_user_id, u.email AS actor_email, al.action, al.target_id, al.payload, al.created_at + FROM audit_log al + LEFT JOIN users u ON u.id = al.actor_user_id + ${where} + ORDER BY al.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`, params ); diff --git a/apps/api/src/routes/bond-annotations.ts b/apps/api/src/routes/bond-annotations.ts index 721afd8..b0bcffb 100644 --- a/apps/api/src/routes/bond-annotations.ts +++ b/apps/api/src/routes/bond-annotations.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { pool } from '../db.js'; import { authMiddleware, - requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest, @@ -43,10 +42,10 @@ bondAnnotationsRouter.post('/', async (req: Request, res: Response) => { // For importers, verify they own the importer record if (isImporter) { - const importer = await pool.query( - `SELECT id FROM importers WHERE id = $1 AND user_id = $2`, - [importer_id, user.id] - ); + const importer = await pool.query(`SELECT id FROM importers WHERE id = $1 AND user_id = $2`, [ + importer_id, + user.id, + ]); if (!importer.rowCount) { res.status(403).json({ error: 'unauthorized' }); return; @@ -54,10 +53,9 @@ bondAnnotationsRouter.post('/', async (req: Request, res: Response) => { } // Get surety_id from the importer - const importerResult = await pool.query( - `SELECT surety_id FROM importers WHERE id = $1`, - [importer_id] - ); + const importerResult = await pool.query(`SELECT surety_id FROM importers WHERE id = $1`, [ + importer_id, + ]); const suretyId = importerResult.rows[0]?.surety_id; if (!suretyId) { res.status(404).json({ error: 'importer not found' }); @@ -91,10 +89,10 @@ bondAnnotationsRouter.get('/:importerId', async (req: Request, res: Response) => } if (isImporter) { - const importer = await pool.query( - `SELECT id FROM importers WHERE id = $1 AND user_id = $2`, - [importerId, user.id] - ); + const importer = await pool.query(`SELECT id FROM importers WHERE id = $1 AND user_id = $2`, [ + importerId, + user.id, + ]); if (!importer.rowCount) { res.status(403).json({ error: 'unauthorized' }); return; @@ -114,7 +112,6 @@ bondAnnotationsRouter.get('/:importerId', async (req: Request, res: Response) => // GET /bond-annotations/event/:eventId — list annotations for a specific event bondAnnotationsRouter.get('/event/:eventId', async (req: Request, res: Response) => { - const user = (req as AuthedRequest).user; const eventId = String(req.params.eventId); const result = await pool.query( @@ -151,7 +148,7 @@ bondAnnotationsRouter.patch('/:id', async (req: Request, res: Response) => { return; } - const annotation = existing.rows[0]; + const annotation = existing.rows[0]!; const isAuthor = annotation.author_id === user.id; const isAdmin = user.role === 'surety_admin'; @@ -165,7 +162,7 @@ bondAnnotationsRouter.patch('/:id', async (req: Request, res: Response) => { SET note = $1, updated_at = now() WHERE id = $2 RETURNING id, event_id, importer_id, author_id, author_role, note, created_at, updated_at`, - [parse.data.note, annotationId] + [parse.data.note.trim(), annotationId] ); res.json({ annotation: result.rows[0] }); @@ -186,7 +183,7 @@ bondAnnotationsRouter.delete('/:id', async (req: Request, res: Response) => { return; } - const annotation = existing.rows[0]; + const annotation = existing.rows[0]!; const isAuthor = annotation.author_id === user.id; const isAdmin = user.role === 'surety_admin'; diff --git a/apps/api/src/routes/importers.ts b/apps/api/src/routes/importers.ts index 0251480..90200c6 100644 --- a/apps/api/src/routes/importers.ts +++ b/apps/api/src/routes/importers.ts @@ -494,9 +494,10 @@ importersRouter.get('/:id/collateral-status', async (req: Request, res: Response // --- Synthetic CBP tariff CSV upload — recomputes required_collateral on-chain --- const TariffLineItemSchema = z.object({ - htsCode: z.string(), + sku: z.string().optional(), + htsCode: z.string().optional(), value: z.coerce.number().positive(), - dutyRate: z.coerce.number().min(0), + dutyRate: z.coerce.number().min(0).optional(), }); const TariffUploadSchema = z.object({ @@ -581,11 +582,70 @@ importersRouter.post('/:id/upload-tariff-csv', async (req: Request, res: Respons return; } + // ── SKU Mapping Resolution ──────────────────────────────────────────────── + // Resolve any items carrying a product SKU against the importer's active catalog mapping. + const activeMappings = await pool.query( + 'SELECT sku, hts_code, duty_rate FROM importer_sku_mappings WHERE importer_id = $1 AND is_active = true', + [importer.id] + ); + const skuMap = new Map(); + for (const row of activeMappings.rows) { + skuMap.set(row.sku, { + htsCode: row.hts_code, + dutyRate: + row.duty_rate !== null && row.duty_rate !== undefined ? Number(row.duty_rate) : undefined, + }); + } + + const unmappedSkus: string[] = []; + const resolvedLineItems: Array<{ + sku?: string; + htsCode: string; + value: number; + dutyRate: number; + }> = []; + + for (const item of parse.data.lineItems) { + let htsCode = item.htsCode; + let dutyRate = item.dutyRate; + + if (!htsCode && item.sku) { + const mapped = skuMap.get(item.sku); + if (mapped) { + htsCode = mapped.htsCode; + if (dutyRate === undefined && mapped.dutyRate !== undefined) { + dutyRate = mapped.dutyRate; + } + } else { + unmappedSkus.push(item.sku); + } + } else if (!htsCode) { + unmappedSkus.push(item.sku || 'UNKNOWN_SKU'); + } + + if (htsCode) { + resolvedLineItems.push({ + sku: item.sku, + htsCode, + value: item.value, + dutyRate: dutyRate ?? 0, + }); + } + } + + if (unmappedSkus.length > 0) { + res.status(422).json({ + error: 'Unmapped SKUs found in tariff upload', + unmappedSkus: Array.from(new Set(unmappedSkus)), + }); + return; + } + // ── HTS statutory rate validation ────────────────────────────────────────── // Cross-reference every line item's declared duty rate against the USITC HTS // schedule before feeding the rates into the collateral computation. const htsValidation = await validateHtsRates( - parse.data.lineItems.map((item) => ({ + resolvedLineItems.map((item) => ({ hts_code: item.htsCode, declared_rate: item.dutyRate, })) @@ -618,7 +678,7 @@ importersRouter.post('/:id/upload-tariff-csv', async (req: Request, res: Respons const validationReport = []; let hasBlockError = false; - for (const item of parse.data.lineItems) { + for (const item of resolvedLineItems) { const cbpRes = await lookupCbpDutyRate(item.htsCode); const cbpRate = cbpRes.dutyRate ?? item.dutyRate; @@ -840,6 +900,271 @@ importersRouter.post('/:id/auto-top-up', async (req: Request, res: Response) => res.status(202).json({ jobId, statusUrl: `/importers/${importer.id}/tx-status/${jobId}` }); }); +// ── #1038: Dual Sign-Off Approval Configuration & Withdrawal Workflow ─────── + +const DualApprovalConfigSchema = z.object({ + enabled: z.boolean(), + thresholdStroops: z.string().regex(/^\d+$/), + secondApproverId: z.string().uuid().nullable().optional(), + secondApproverEmail: z.string().email().nullable().optional(), +}); + +importersRouter.get('/:id/dual-approval', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + res.json({ + enabled: Boolean(importer.dual_approval_enabled), + thresholdStroops: String(importer.dual_approval_threshold_stroops ?? '0'), + secondApproverId: importer.second_approver_id ?? null, + secondApproverEmail: importer.second_approver_email ?? null, + }); +}); + +importersRouter.put('/:id/dual-approval', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + if (user.role !== 'surety_admin' && importer.user_id !== user.id) { + res.status(403).json({ error: 'forbidden' }); + return; + } + + const parse = DualApprovalConfigSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + + const { enabled, thresholdStroops, secondApproverId, secondApproverEmail } = parse.data; + + const result = await pool.query( + `UPDATE importers + SET dual_approval_enabled = $1, + dual_approval_threshold_stroops = $2, + second_approver_id = $3, + second_approver_email = $4 + WHERE id = $5 + RETURNING id, dual_approval_enabled, dual_approval_threshold_stroops, second_approver_id, second_approver_email`, + [enabled, thresholdStroops, secondApproverId ?? null, secondApproverEmail ?? null, importer.id] + ); + + await logAudit(user.id, 'dual_approval_configured', importer.id, { + enabled, + thresholdStroops, + secondApproverId, + secondApproverEmail, + }); + + res.json({ + enabled: Boolean(result.rows[0]?.dual_approval_enabled), + thresholdStroops: String(result.rows[0]?.dual_approval_threshold_stroops ?? '0'), + secondApproverId: result.rows[0]?.second_approver_id ?? null, + secondApproverEmail: result.rows[0]?.second_approver_email ?? null, + }); +}); + +importersRouter.get('/:id/withdrawal-requests', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const requests = await pool.query( + `SELECT wr.id, wr.importer_id, wr.requested_by, u.email AS requested_by_email, + wr.amount_stroops, wr.status, wr.second_approver_id, sa.email AS second_approver_email, + wr.approved_by, ap.email AS approved_by_email, wr.rejected_by, rj.email AS rejected_by_email, + wr.rejection_reason, wr.job_id, wr.created_at, wr.resolved_at + FROM withdrawal_requests wr + LEFT JOIN users u ON u.id = wr.requested_by + LEFT JOIN users sa ON sa.id = wr.second_approver_id + LEFT JOIN users ap ON ap.id = wr.approved_by + LEFT JOIN users rj ON rj.id = wr.rejected_by + WHERE wr.importer_id = $1 + ORDER BY wr.created_at DESC`, + [importer.id] + ); + + res.json({ requests: requests.rows }); +}); + +importersRouter.post( + '/:id/withdrawal-requests/:requestId/approve', + async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const reqResult = await pool.query( + 'SELECT * FROM withdrawal_requests WHERE id = $1 AND importer_id = $2', + [req.params.requestId, importer.id] + ); + const wr = reqResult.rows[0]; + if (!wr) { + res.status(404).json({ error: 'withdrawal request not found' }); + return; + } + + if (wr.status !== 'pending') { + res.status(400).json({ error: `Cannot approve request with status: ${wr.status}` }); + return; + } + + if (wr.requested_by === user.id && user.role !== 'surety_admin') { + res.status(403).json({ error: 'requester cannot self-approve dual sign-off withdrawal' }); + return; + } + + const amlRes = await screenWalletAddress(importer.stellar_address); + if (amlRes.riskScore === 'HIGH') { + res.status(403).json({ error: 'Transaction blocked pending AML review' }); + return; + } + + const jobId = await enqueueTxSubmit({ + method: 'withdraw', + importerId: importer.id, + keypairSecret: importer.stellar_secret_encrypted, + args: { + importerAddress: importer.stellar_address, + sourceAddress: importer.stellar_address, + amountStroops: wr.amount_stroops, + }, + }); + + await pool.query( + `UPDATE withdrawal_requests + SET status = 'approved', + approved_by = $1, + job_id = $2, + resolved_at = now() + WHERE id = $3`, + [user.id, jobId, wr.id] + ); + + await logAudit(user.id, 'withdraw_approved', importer.id, { + withdrawalRequestId: wr.id, + amountStroops: wr.amount_stroops, + jobId, + }); + + await invalidateOnChainAccount(importer.id); + + res.json({ + status: 'approved', + jobId, + statusUrl: `/importers/${importer.id}/tx-status/${jobId}`, + }); + } +); + +importersRouter.post( + '/:id/withdrawal-requests/:requestId/reject', + async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const reqResult = await pool.query( + 'SELECT * FROM withdrawal_requests WHERE id = $1 AND importer_id = $2', + [req.params.requestId, importer.id] + ); + const wr = reqResult.rows[0]; + if (!wr) { + res.status(404).json({ error: 'withdrawal request not found' }); + return; + } + + if (wr.status !== 'pending') { + res.status(400).json({ error: `Cannot reject request with status: ${wr.status}` }); + return; + } + + const reason = req.body?.reason ? String(req.body.reason) : null; + + await pool.query( + `UPDATE withdrawal_requests + SET status = 'rejected', + rejected_by = $1, + rejection_reason = $2, + resolved_at = now() + WHERE id = $3`, + [user.id, reason, wr.id] + ); + + await logAudit(user.id, 'withdraw_rejected', importer.id, { + withdrawalRequestId: wr.id, + amountStroops: wr.amount_stroops, + reason, + }); + + res.json({ status: 'rejected' }); + } +); + +importersRouter.post( + '/:id/withdrawal-requests/:requestId/cancel', + async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const reqResult = await pool.query( + 'SELECT * FROM withdrawal_requests WHERE id = $1 AND importer_id = $2', + [req.params.requestId, importer.id] + ); + const wr = reqResult.rows[0]; + if (!wr) { + res.status(404).json({ error: 'withdrawal request not found' }); + return; + } + + if (wr.status !== 'pending') { + res.status(400).json({ error: `Cannot cancel request with status: ${wr.status}` }); + return; + } + + if (wr.requested_by !== user.id && user.role !== 'surety_admin') { + res + .status(403) + .json({ error: 'Only the original requester can cancel a pending withdrawal' }); + return; + } + + await pool.query( + `UPDATE withdrawal_requests + SET status = 'cancelled', + resolved_at = now() + WHERE id = $1`, + [wr.id] + ); + + await logAudit(user.id, 'withdraw_cancelled', importer.id, { + withdrawalRequestId: wr.id, + amountStroops: wr.amount_stroops, + }); + + res.json({ status: 'cancelled' }); + } +); + const WithdrawSchema = z.object({ amountStroops: z.string().regex(/^\d+$/), }); @@ -873,6 +1198,32 @@ importersRouter.post('/:id/withdraw', async (req: Request, res: Response) => { return; } + // Dual sign-off threshold check (#1038) + const dualEnabled = Boolean(importer.dual_approval_enabled); + const threshold = BigInt(importer.dual_approval_threshold_stroops ?? '0'); + const amountStroops = BigInt(parse.data.amountStroops); + + if (dualEnabled && threshold > 0n && amountStroops >= threshold) { + const reqInsert = await pool.query( + `INSERT INTO withdrawal_requests (importer_id, requested_by, amount_stroops, status, second_approver_id) + VALUES ($1, $2, $3, 'pending', $4) + RETURNING id, importer_id, requested_by, amount_stroops, status, second_approver_id, created_at`, + [importer.id, user.id, parse.data.amountStroops, importer.second_approver_id ?? null] + ); + const requestRow = reqInsert.rows[0]!; + await logAudit(user.id, 'withdraw_requested_pending_approval', importer.id, { + amountStroops: parse.data.amountStroops, + withdrawalRequestId: requestRow.id, + }); + res.status(202).json({ + status: 'pending_approval', + withdrawalRequestId: requestRow.id, + amountStroops: parse.data.amountStroops, + message: 'Withdrawal amount exceeds dual sign-off threshold and requires second approval.', + }); + return; + } + const jobId = await enqueueTxSubmit({ method: 'withdraw', importerId: importer.id, @@ -892,6 +1243,358 @@ importersRouter.post('/:id/withdraw', async (req: Request, res: Response) => { res.status(202).json({ jobId, statusUrl: `/importers/${importer.id}/tx-status/${jobId}` }); }); +// ── #1040: Bulk HS Code Mapping Table Import for Product Catalogs ─────────── + +const SkuMappingItemSchema = z.object({ + sku: z.string().min(1), + htsCode: z.string().min(1), + description: z.string().optional(), + dutyRate: z.coerce.number().min(0).optional(), +}); + +const BulkSkuMappingSchema = z.object({ + mappings: z.array(SkuMappingItemSchema).optional(), + csvText: z.string().optional(), +}); + +importersRouter.post('/:id/sku-mappings/bulk', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const parse = BulkSkuMappingSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + + let items: Array<{ sku: string; htsCode: string; description?: string; dutyRate?: number }> = []; + + if (parse.data.mappings && parse.data.mappings.length > 0) { + items = parse.data.mappings; + } else if (parse.data.csvText) { + const lines = parse.data.csvText + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length > 0) { + const firstLine = lines[0]!.toLowerCase(); + const hasHeader = firstLine.includes('sku') || firstLine.includes('hts'); + const dataLines = hasHeader ? lines.slice(1) : lines; + + for (const line of dataLines) { + const parts = line.split(',').map((p) => p.trim().replace(/^["']|["']$/g, '')); + if (parts.length >= 2 && parts[0] && parts[1]) { + const sku = parts[0]; + const htsCode = parts[1]; + const description = parts[2] || undefined; + const dutyRate = parts[3] && !isNaN(Number(parts[3])) ? Number(parts[3]) : undefined; + items.push({ sku, htsCode, description, dutyRate }); + } + } + } + } + + if (items.length === 0) { + res.status(400).json({ error: 'no valid SKU mapping entries found in payload' }); + return; + } + + const vRes = await pool.query<{ max_version: number | null }>( + 'SELECT MAX(version) AS max_version FROM importer_sku_mappings WHERE importer_id = $1', + [importer.id] + ); + const nextVersion = (vRes.rows[0]?.max_version ?? 0) + 1; + + await pool.query('UPDATE importer_sku_mappings SET is_active = false WHERE importer_id = $1', [ + importer.id, + ]); + + for (const it of items) { + await pool.query( + `INSERT INTO importer_sku_mappings (importer_id, version, sku, hts_code, description, duty_rate, is_active) + VALUES ($1, $2, $3, $4, $5, $6, true) + ON CONFLICT (importer_id, version, sku) DO UPDATE + SET hts_code = EXCLUDED.hts_code, + description = EXCLUDED.description, + duty_rate = EXCLUDED.duty_rate, + updated_at = now()`, + [importer.id, nextVersion, it.sku, it.htsCode, it.description ?? null, it.dutyRate ?? null] + ); + } + + await logAudit(user.id, 'sku_mappings_imported', importer.id, { + version: nextVersion, + count: items.length, + }); + + res.status(201).json({ + success: true, + version: nextVersion, + count: items.length, + }); +}); + +importersRouter.get('/:id/sku-mappings', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const { search, version, page = '1', per_page = '50' } = req.query as Record; + const pageNum = Math.max(1, parseInt(page, 10) || 1); + const limit = Math.min(200, Math.max(1, parseInt(per_page, 10) || 50)); + const offset = (pageNum - 1) * limit; + + const conditions = ['importer_id = $1']; + const params: unknown[] = [importer.id]; + + if (version) { + params.push(parseInt(version, 10)); + conditions.push(`version = $${params.length}`); + } else { + conditions.push('is_active = true'); + } + + if (search && search.trim().length > 0) { + params.push(`%${search.trim()}%`); + conditions.push( + `(sku ILIKE $${params.length} OR hts_code ILIKE $${params.length} OR description ILIKE $${params.length})` + ); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const countResult = await pool.query<{ count: string }>( + `SELECT COUNT(*) AS count FROM importer_sku_mappings ${where}`, + params + ); + const total = parseInt(countResult.rows[0]?.count ?? '0', 10); + + params.push(limit, offset); + const rows = await pool.query( + `SELECT id, importer_id, version, sku, hts_code, description, duty_rate, is_active, created_at, updated_at + FROM importer_sku_mappings + ${where} + ORDER BY sku ASC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params + ); + + res.json({ + mappings: rows.rows, + pagination: { + total, + page: pageNum, + per_page: limit, + total_pages: Math.ceil(total / limit), + }, + }); +}); + +importersRouter.post('/:id/sku-mappings', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const parse = SkuMappingItemSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + + const { sku, htsCode, description, dutyRate } = parse.data; + + const vRes = await pool.query<{ version: number | null }>( + 'SELECT version FROM importer_sku_mappings WHERE importer_id = $1 AND is_active = true LIMIT 1', + [importer.id] + ); + const currentVersion = vRes.rows[0]?.version ?? 1; + + const result = await pool.query( + `INSERT INTO importer_sku_mappings (importer_id, version, sku, hts_code, description, duty_rate, is_active) + VALUES ($1, $2, $3, $4, $5, $6, true) + ON CONFLICT (importer_id, version, sku) DO UPDATE + SET hts_code = EXCLUDED.hts_code, + description = EXCLUDED.description, + duty_rate = EXCLUDED.duty_rate, + is_active = true, + updated_at = now() + RETURNING id, importer_id, version, sku, hts_code, description, duty_rate, is_active, created_at, updated_at`, + [importer.id, currentVersion, sku, htsCode, description ?? null, dutyRate ?? null] + ); + + await logAudit(user.id, 'sku_mapping_created', importer.id, { sku, htsCode }); + + res.status(201).json({ mapping: result.rows[0] }); +}); + +importersRouter.put('/:id/sku-mappings/:mappingId', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const parse = SkuMappingItemSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + + const { sku, htsCode, description, dutyRate } = parse.data; + + const result = await pool.query( + `UPDATE importer_sku_mappings + SET sku = $1, + hts_code = $2, + description = $3, + duty_rate = $4, + updated_at = now() + WHERE id = $5 AND importer_id = $6 + RETURNING id, importer_id, version, sku, hts_code, description, duty_rate, is_active, created_at, updated_at`, + [sku, htsCode, description ?? null, dutyRate ?? null, req.params.mappingId, importer.id] + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'mapping entry not found' }); + return; + } + + await logAudit(user.id, 'sku_mapping_updated', importer.id, { + mappingId: req.params.mappingId, + sku, + htsCode, + }); + + res.json({ mapping: result.rows[0] }); +}); + +importersRouter.delete('/:id/sku-mappings/:mappingId', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const result = await pool.query( + 'DELETE FROM importer_sku_mappings WHERE id = $1 AND importer_id = $2 RETURNING id, sku', + [req.params.mappingId, importer.id] + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'mapping entry not found' }); + return; + } + + await logAudit(user.id, 'sku_mapping_deleted', importer.id, { + mappingId: req.params.mappingId, + sku: result.rows[0]?.sku, + }); + + res.json({ success: true }); +}); + +// ── #1041: Consolidated Document Expiration Calendar View ──────────────────── + +export interface ComplianceExpirationItem { + id: string; + entityType: 'kyc' | 'surety_license'; + title: string; + documentType: string; + expirationDate: string; + daysUntilExpiration: number; + urgency: 'critical' | 'warning' | 'upcoming' | 'normal'; + deepLink: string; + metadata?: Record; +} + +importersRouter.get('/:id/compliance-calendar', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); + if (!importer) { + res.status(404).json({ error: 'not found' }); + return; + } + + const items: ComplianceExpirationItem[] = []; + const now = Date.now(); + + const kycDocs = await pool.query( + `SELECT id, document_type, document_name, upload_timestamp, expiration_date, scheduled_deletion_date, review_status + FROM kyc_documents + WHERE importer_id = $1 AND deleted_at IS NULL`, + [importer.id] + ); + + for (const doc of kycDocs.rows) { + const expiry = doc.expiration_date + ? new Date(doc.expiration_date) + : doc.upload_timestamp + ? new Date(new Date(doc.upload_timestamp).getTime() + 365 * 24 * 60 * 60 * 1000) + : null; + + if (expiry) { + const daysUntil = Math.ceil((expiry.getTime() - now) / (1000 * 60 * 60 * 24)); + let urgency: 'critical' | 'warning' | 'upcoming' | 'normal' = 'normal'; + if (daysUntil <= 30) urgency = 'critical'; + else if (daysUntil <= 60) urgency = 'warning'; + else if (daysUntil <= 90) urgency = 'upcoming'; + + const docName = doc.document_name || doc.document_type.replace(/_/g, ' ').toUpperCase(); + items.push({ + id: doc.id, + entityType: 'kyc', + title: `KYC: ${docName}`, + documentType: doc.document_type, + expirationDate: expiry.toISOString(), + daysUntilExpiration: daysUntil, + urgency, + deepLink: `/app?tab=kyc`, + metadata: { reviewStatus: doc.review_status }, + }); + } + } + + const suretyLicenses = await pool.query( + 'SELECT id, state_code, license_number, expiration_date, status, renewal_url FROM surety_state_licenses' + ); + + for (const lic of suretyLicenses.rows) { + if (lic.expiration_date) { + const expiry = new Date(lic.expiration_date); + const daysUntil = Math.ceil((expiry.getTime() - now) / (1000 * 60 * 60 * 24)); + let urgency: 'critical' | 'warning' | 'upcoming' | 'normal' = 'normal'; + if (daysUntil <= 30) urgency = 'critical'; + else if (daysUntil <= 60) urgency = 'warning'; + else if (daysUntil <= 90) urgency = 'upcoming'; + + items.push({ + id: lic.id, + entityType: 'surety_license', + title: `Surety License (${lic.state_code}): ${lic.license_number}`, + documentType: 'surety_state_license', + expirationDate: expiry.toISOString(), + daysUntilExpiration: daysUntil, + urgency, + deepLink: lic.renewal_url || '/surety-license/submit', + metadata: { status: lic.status, stateCode: lic.state_code }, + }); + } + } + + items.sort((a, b) => new Date(a.expirationDate).getTime() - new Date(b.expirationDate).getTime()); + + res.json({ items }); +}); + // --- Surety admin actions --- const YieldSchema = z.object({ amountStroops: z.string().regex(/^\d+$/) }); diff --git a/apps/api/src/routes/sla.ts b/apps/api/src/routes/sla.ts index c2ec2d7..3f9c4fe 100644 --- a/apps/api/src/routes/sla.ts +++ b/apps/api/src/routes/sla.ts @@ -55,10 +55,9 @@ slaRouter.post('/business-hours', async (req: Request, res: Response) => { const data = parse.data; const suretyId = user.id; - const existing = await pool.query( - `SELECT id FROM business_hours_config WHERE surety_id = $1`, - [suretyId] - ); + const existing = await pool.query(`SELECT id FROM business_hours_config WHERE surety_id = $1`, [ + suretyId, + ]); if (existing.rowCount) { await pool.query( @@ -75,13 +74,20 @@ slaRouter.post('/business-hours', async (req: Request, res: Response) => { WHERE surety_id = $17`, [ data.timezone, - data.monday_start, data.monday_end, - data.tuesday_start, data.tuesday_end, - data.wednesday_start, data.wednesday_end, - data.thursday_start, data.thursday_end, - data.friday_start, data.friday_end, - data.saturday_start, data.saturday_end, - data.sunday_start, data.sunday_end, + data.monday_start, + data.monday_end, + data.tuesday_start, + data.tuesday_end, + data.wednesday_start, + data.wednesday_end, + data.thursday_start, + data.thursday_end, + data.friday_start, + data.friday_end, + data.saturday_start, + data.saturday_end, + data.sunday_start, + data.sunday_end, JSON.stringify(data.holidays), suretyId, ] @@ -96,14 +102,22 @@ slaRouter.post('/business-hours', async (req: Request, res: Response) => { sunday_start, sunday_end, holidays) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, [ - suretyId, data.timezone, - data.monday_start, data.monday_end, - data.tuesday_start, data.tuesday_end, - data.wednesday_start, data.wednesday_end, - data.thursday_start, data.thursday_end, - data.friday_start, data.friday_end, - data.saturday_start, data.saturday_end, - data.sunday_start, data.sunday_end, + suretyId, + data.timezone, + data.monday_start, + data.monday_end, + data.tuesday_start, + data.tuesday_end, + data.wednesday_start, + data.wednesday_end, + data.thursday_start, + data.thursday_end, + data.friday_start, + data.friday_end, + data.saturday_start, + data.saturday_end, + data.sunday_start, + data.sunday_end, JSON.stringify(data.holidays), ] ); @@ -116,21 +130,27 @@ slaRouter.post('/business-hours', async (req: Request, res: Response) => { slaRouter.get('/business-hours', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const result = await pool.query( - `SELECT * FROM business_hours_config WHERE surety_id = $1`, - [user.id] - ); + const result = await pool.query(`SELECT * FROM business_hours_config WHERE surety_id = $1`, [ + user.id, + ]); if (!result.rowCount) { res.json({ timezone: 'America/New_York', - monday_start: '09:00', monday_end: '17:00', - tuesday_start: '09:00', tuesday_end: '17:00', - wednesday_start: '09:00', wednesday_end: '17:00', - thursday_start: '09:00', thursday_end: '17:00', - friday_start: '09:00', friday_end: '17:00', - saturday_start: null, saturday_end: null, - sunday_start: null, sunday_end: null, + monday_start: '09:00', + monday_end: '17:00', + tuesday_start: '09:00', + tuesday_end: '17:00', + wednesday_start: '09:00', + wednesday_end: '17:00', + thursday_start: '09:00', + thursday_end: '17:00', + friday_start: '09:00', + friday_end: '17:00', + saturday_start: null, + saturday_end: null, + sunday_start: null, + sunday_end: null, holidays: [], }); return; @@ -341,7 +361,7 @@ slaRouter.post('/tracking', async (req: Request, res: Response) => { return; } - const targetHours = parseFloat(targetResult.rows[0].target_hours); + const targetHours = parseFloat(targetResult.rows[0]!.target_hours); const deadline = new Date(Date.now() + targetHours * 3600 * 1000); const result = await pool.query( diff --git a/apps/api/src/routes/upgrade-subscriptions.ts b/apps/api/src/routes/upgrade-subscriptions.ts index 2dd21f4..9bf152e 100644 --- a/apps/api/src/routes/upgrade-subscriptions.ts +++ b/apps/api/src/routes/upgrade-subscriptions.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { pool } from '../db.js'; import { authMiddleware, - requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest, diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx index b24e631..164c6e5 100644 --- a/apps/web/app/app/page.tsx +++ b/apps/web/app/app/page.tsx @@ -22,6 +22,7 @@ import { Nav } from '@/components/Nav'; import { HealthScore } from '@/components/HealthScore'; import { DepositWizard } from '@/components/DepositWizard'; import { BondTimeline } from '@/components/BondTimeline'; +import { ComplianceExpirationCalendar } from '@/components/ComplianceExpirationCalendar'; import { DashboardSkeleton } from '@/components/DashboardSkeleton'; import { Spinner } from '@/components/Spinner'; import { ErrorBanner } from '@/components/ErrorBanner'; @@ -182,8 +183,8 @@ function ImporterDashboard() { {onc.isClawbacked ? (
Account frozen by surety. All collateral + reserve has been clawed - back. No further deposits or withdrawals allowed. Contact your surety support team - and review the on-chain event log below before taking another action. + back. No further deposits or withdrawals allowed. Contact your surety support team and + review the on-chain event log below before taking another action.
) : null} @@ -329,6 +330,10 @@ function ImporterDashboard() { +
+ +
+

On-chain event log diff --git a/apps/web/app/surety/[id]/page.tsx b/apps/web/app/surety/[id]/page.tsx index 024b7da..41cb6a0 100644 --- a/apps/web/app/surety/[id]/page.tsx +++ b/apps/web/app/surety/[id]/page.tsx @@ -126,8 +126,8 @@ export default function SuretyImporterDetail() { {onc.isClawbacked ? (
Account frozen. Clawback already executed. - Contact the importer and review the on-chain event log below before taking further - admin action. + Contact the importer and review the on-chain event log below before taking further admin + action.
) : null} @@ -195,7 +195,7 @@ export default function SuretyImporterDetail() {

) : null} - {error ? ( -

- {error} -

- ) : null} -
diff --git a/apps/web/app/surety/audit-log/page.tsx b/apps/web/app/surety/audit-log/page.tsx new file mode 100644 index 0000000..9c9a405 --- /dev/null +++ b/apps/web/app/surety/audit-log/page.tsx @@ -0,0 +1,414 @@ +'use client'; + +import { Suspense, useEffect, useState, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Nav } from '@/components/Nav'; +import { api, type AuditLogEntry } from '@/lib/api'; +import { getUser, isAuthenticated } from '@/lib/auth'; + +function AuditLogContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Search & Filter state initialized from URL params + const [search, setSearch] = useState(searchParams.get('search') || ''); + const [action, setAction] = useState(searchParams.get('action') || ''); + const [actorUserId, setActorUserId] = useState(searchParams.get('actor_user_id') || ''); + const [fromDate, setFromDate] = useState( + searchParams.get('from') ? searchParams.get('from')!.slice(0, 10) : '' + ); + const [toDate, setToDate] = useState( + searchParams.get('to') ? searchParams.get('to')!.slice(0, 10) : '' + ); + const [page, setPage] = useState(parseInt(searchParams.get('page') || '1', 10)); + const perPage = 25; + + const [expandedId, setExpandedId] = useState(null); + + const syncUrlParams = useCallback( + (newParams: Record) => { + const sp = new URLSearchParams(); + if (newParams.search) sp.set('search', String(newParams.search)); + if (newParams.action) sp.set('action', String(newParams.action)); + if (newParams.actor_user_id) sp.set('actor_user_id', String(newParams.actor_user_id)); + if (newParams.from) sp.set('from', String(newParams.from)); + if (newParams.to) sp.set('to', String(newParams.to)); + if (newParams.page && Number(newParams.page) > 1) sp.set('page', String(newParams.page)); + router.replace(`/surety/audit-log?${sp.toString()}`); + }, + [router] + ); + + const fetchAuditLogs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const fromIso = fromDate ? new Date(`${fromDate}T00:00:00.000Z`).toISOString() : undefined; + const toIso = toDate ? new Date(`${toDate}T23:59:59.999Z`).toISOString() : undefined; + + const res = await api.getAuditLog({ + search: search.trim() || undefined, + action: action.trim() || undefined, + actor_user_id: actorUserId.trim() || undefined, + from: fromIso, + to: toIso, + page, + per_page: perPage, + }); + + setEntries(res.data); + setTotal(res.pagination.total); + setTotalPages(res.pagination.total_pages); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load audit logs'); + } finally { + setLoading(false); + } + }, [search, action, actorUserId, fromDate, toDate, page]); + + useEffect(() => { + if (!isAuthenticated()) { + router.replace('/login'); + return; + } + const user = getUser(); + if (user?.role !== 'surety_admin') { + router.replace('/app'); + return; + } + fetchAuditLogs(); + }, [router, fetchAuditLogs]); + + const handleFilterSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setPage(1); + const fromIso = fromDate ? new Date(`${fromDate}T00:00:00.000Z`).toISOString() : undefined; + const toIso = toDate ? new Date(`${toDate}T23:59:59.999Z`).toISOString() : undefined; + + syncUrlParams({ + search: search.trim() || undefined, + action: action.trim() || undefined, + actor_user_id: actorUserId.trim() || undefined, + from: fromIso, + to: toIso, + page: 1, + }); + }; + + const handleResetFilters = () => { + setSearch(''); + setAction(''); + setActorUserId(''); + setFromDate(''); + setToDate(''); + setPage(1); + router.replace('/surety/audit-log'); + }; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + const fromIso = fromDate ? new Date(`${fromDate}T00:00:00.000Z`).toISOString() : undefined; + const toIso = toDate ? new Date(`${toDate}T23:59:59.999Z`).toISOString() : undefined; + + syncUrlParams({ + search: search.trim() || undefined, + action: action.trim() || undefined, + actor_user_id: actorUserId.trim() || undefined, + from: fromIso, + to: toIso, + page: newPage, + }); + }; + + const handleExportCsv = () => { + const fromIso = fromDate ? new Date(`${fromDate}T00:00:00.000Z`).toISOString() : undefined; + const toIso = toDate ? new Date(`${toDate}T23:59:59.999Z`).toISOString() : undefined; + + const csvUrl = api.getAuditLogCsvUrl({ + search: search.trim() || undefined, + action: action.trim() || undefined, + actor_user_id: actorUserId.trim() || undefined, + from: fromIso, + to: toIso, + }); + + window.open(csvUrl, '_blank'); + }; + + return ( + <> +
+ } + > + + + ); +} diff --git a/apps/web/app/surety/page.tsx b/apps/web/app/surety/page.tsx index 228cfc2..666fcd5 100644 --- a/apps/web/app/surety/page.tsx +++ b/apps/web/app/surety/page.tsx @@ -68,10 +68,35 @@ export default function SuretyDashboard() { <>