diff --git a/apps/api/migrations/009_compliance_escalation_and_marketplace.down.sql b/apps/api/migrations/009_compliance_escalation_and_marketplace.down.sql new file mode 100644 index 0000000..b3574d9 --- /dev/null +++ b/apps/api/migrations/009_compliance_escalation_and_marketplace.down.sql @@ -0,0 +1,17 @@ +-- Rollback migration #009: Compliance Escalation Rules and Surety Marketplace + +DROP INDEX IF EXISTS idx_surety_marketplace_partners_rating; +DROP INDEX IF EXISTS idx_surety_marketplace_partners_active; +DROP TABLE IF EXISTS surety_marketplace_partners; + +DROP INDEX IF EXISTS idx_compliance_case_notes_flag; +DROP TABLE IF EXISTS compliance_case_notes; + +DROP INDEX IF EXISTS idx_compliance_escalation_history_flag; +DROP TABLE IF EXISTS compliance_escalation_history; + +DROP INDEX IF EXISTS idx_compliance_escalation_rules_surety; +DROP TABLE IF EXISTS compliance_escalation_rules; + +-- Note: We don't drop compliance_flags columns, notifications, or audit_log +-- as they may be used by other features diff --git a/apps/api/migrations/009_compliance_escalation_and_marketplace.sql b/apps/api/migrations/009_compliance_escalation_and_marketplace.sql new file mode 100644 index 0000000..73d50ca --- /dev/null +++ b/apps/api/migrations/009_compliance_escalation_and_marketplace.sql @@ -0,0 +1,109 @@ +-- Migration #009: Compliance Escalation Rules and Surety Marketplace +-- Issue #1034: Automated escalation rules for unresolved compliance flags +-- Issue #1036: Surety partner rate comparison marketplace + +-- Compliance escalation rules table +CREATE TABLE IF NOT EXISTS compliance_escalation_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + surety_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + age_threshold_hours INTEGER NOT NULL CHECK (age_threshold_hours > 0), + escalation_target_role TEXT NOT NULL CHECK (escalation_target_role IN ('senior_admin', 'specific_user')), + escalation_target_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_compliance_escalation_rules_surety + ON compliance_escalation_rules(surety_id, is_active); + +-- Compliance escalation history table +CREATE TABLE IF NOT EXISTS compliance_escalation_history ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + flag_id UUID NOT NULL REFERENCES compliance_flags(id) ON DELETE CASCADE, + escalation_rule_id UUID NOT NULL REFERENCES compliance_escalation_rules(id) ON DELETE SET NULL, + previous_assignee UUID REFERENCES users(id) ON DELETE SET NULL, + new_assignee UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL, + escalated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_compliance_escalation_history_flag + ON compliance_escalation_history(flag_id, escalated_at DESC); + +-- Add missing columns to compliance_flags if not already present +ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS assigned_to UUID REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS case_status TEXT NOT NULL DEFAULT 'new' + CHECK (case_status IN ('new', 'investigating', 'escalated', 'resolved')); +ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'medium' + CHECK (priority IN ('low', 'medium', 'high', 'critical')); + +-- Case notes for compliance flags +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) ON DELETE CASCADE, + 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); + +-- Surety marketplace partners table +CREATE TABLE IF NOT EXISTS surety_marketplace_partners ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + surety_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + company_name TEXT NOT NULL, + naic_number TEXT, + am_best_rating TEXT, + collateral_ratio NUMERIC(5, 2) NOT NULL CHECK (collateral_ratio > 0), + coverage_types TEXT[] NOT NULL DEFAULT ARRAY['continuous'], + base_premium_rate NUMERIC(5, 4) NOT NULL CHECK (base_premium_rate > 0), + description TEXT, + min_bond_amount NUMERIC(20, 2) NOT NULL, + max_bond_amount NUMERIC(20, 2) NOT NULL, + states_licensed_count INTEGER NOT NULL DEFAULT 0, + contact_email TEXT NOT NULL, + contact_phone TEXT, + website_url TEXT, + stellar_contract_address TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_surety_marketplace_partners_active + ON surety_marketplace_partners(is_active, is_published, collateral_ratio); + +CREATE INDEX IF NOT EXISTS idx_surety_marketplace_partners_rating + ON surety_marketplace_partners(am_best_rating DESC NULLS LAST) WHERE is_published = TRUE; + +-- Add notifications table if not exists (referenced by escalation job) +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + message TEXT NOT NULL, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user + ON notifications(user_id, created_at DESC) WHERE read_at IS NULL; + +-- Add audit_log table if not exists (referenced by compliance operations) +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_id TEXT, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created + ON audit_log(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_log_actor + ON audit_log(actor_user_id, created_at DESC); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 43ce4e0..05dd0d8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -39,6 +39,8 @@ import { onboardingRouter } from './routes/onboarding.js'; import { apiKeyUsageMeter } from './services/api-key-usage.js'; import { startApiKeyUsagePruneScheduler } from './jobs/prune-api-key-usage.js'; import { startOnboardingDripScheduler } from './services/onboarding-drip.js'; +import { suretyMarketplaceRouter, adminMarketplaceRouter } from './routes/surety-marketplace.js'; +import { startComplianceEscalation } from './jobs/compliance-escalation.js'; const app = express(); app.use(httpLogger); @@ -339,6 +341,8 @@ app.use('/onboarding', onboardingRouter); app.use('/api/v1/regulatory', regulatoryRouter); app.use('/bonds', bondWebhookRouter); // unauthenticated DocuSign webhook app.use('/api', bondSignaturesRouter); // authenticated bond signature routes +app.use('/surety-marketplace', suretyMarketplaceRouter); +app.use('/surety-marketplace', adminMarketplaceRouter); Sentry.setupExpressErrorHandler(app); @@ -365,6 +369,7 @@ async function start() { startSlaBreachChecker(); startApiKeyUsagePruneScheduler(); startOnboardingDripScheduler(); + startComplianceEscalation(); app.listen(env.PORT, () => { logger.info( { diff --git a/apps/api/src/jobs/compliance-escalation.ts b/apps/api/src/jobs/compliance-escalation.ts new file mode 100644 index 0000000..6d09c3e --- /dev/null +++ b/apps/api/src/jobs/compliance-escalation.ts @@ -0,0 +1,134 @@ +import { pool, createNotification } from '../db.js'; +import { logger } from '../lib/logger.js'; + +/** + * Periodic job that checks for compliance flags exceeding age thresholds + * and escalates them according to configured rules. + * + * Issue #1034: Automated Escalation Rules for Unresolved Compliance Flags + * + * Runs every 15 minutes to check unresolved flags against active escalation rules. + */ +export function startComplianceEscalation(): void { + const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes + + async function checkAndEscalate(): Promise { + try { + // Get all active escalation rules + const rulesResult = await pool.query<{ + id: string; + surety_id: string; + age_threshold_hours: number; + escalation_target_role: string; + escalation_target_user_id: string | null; + }>( + `SELECT id, surety_id, age_threshold_hours, escalation_target_role, escalation_target_user_id + FROM compliance_escalation_rules + WHERE is_active = TRUE` + ); + + if (!rulesResult.rowCount) { + return; + } + + for (const rule of rulesResult.rows) { + // Find flags that exceed the threshold and haven't been escalated yet + const flagsResult = await pool.query<{ + id: string; + importer_id: string; + flag_type: string; + severity: string; + age_hours: number; + }>( + `SELECT cf.id, cf.importer_id, cf.flag_type, cf.severity, + EXTRACT(EPOCH FROM (now() - cf.created_at)) / 3600 AS age_hours + FROM compliance_flags cf + WHERE cf.surety_id = $1 + AND cf.resolution_status = 'open' + AND cf.case_status != 'escalated' + AND EXTRACT(EPOCH FROM (now() - cf.created_at)) / 3600 > $2`, + [rule.surety_id, rule.age_threshold_hours] + ); + + if (!flagsResult.rowCount) { + continue; + } + + // Determine escalation target + let targetUserId = rule.escalation_target_user_id; + if (!targetUserId && rule.escalation_target_role === 'senior_admin') { + // Find a senior admin for this surety + const targetResult = await pool.query<{ id: string }>( + `SELECT id FROM users + WHERE role = 'surety_admin' + AND id = $1 + LIMIT 1`, + [rule.surety_id] + ); + targetUserId = targetResult.rows[0]?.id; + } + + if (!targetUserId) { + logger.warn({ ruleId: rule.id }, 'No escalation target found for rule'); + continue; + } + + // Escalate each flag + for (const flag of flagsResult.rows) { + try { + // Update flag status to escalated and reassign + await pool.query( + `UPDATE compliance_flags + SET case_status = 'escalated', + assigned_to = $1, + priority = CASE + WHEN priority = 'low' THEN 'medium' + WHEN priority = 'medium' THEN 'high' + WHEN priority = 'high' THEN 'critical' + ELSE priority + END, + updated_at = now() + WHERE id = $2`, + [targetUserId, flag.id] + ); + + // Record escalation history + await pool.query( + `INSERT INTO compliance_escalation_history + (flag_id, escalation_rule_id, previous_assignee, new_assignee, escalated_at) + VALUES ($1, $2, + (SELECT assigned_to FROM compliance_flags WHERE id = $1), + $3, now())`, + [flag.id, rule.id, targetUserId] + ); + + // Notify the escalation target + const message = `Compliance flag escalated: ${flag.flag_type.replace(/_/g, ' ')} (${flag.severity}) has exceeded ${rule.age_threshold_hours}h threshold. Age: ${Math.round(flag.age_hours)}h`; + await createNotification(targetUserId, 'compliance_escalation', message); + + logger.info( + { + flagId: flag.id, + ruleId: rule.id, + targetUserId, + ageHours: flag.age_hours, + }, + 'Compliance flag escalated' + ); + } catch (err) { + logger.error( + { err, flagId: flag.id, ruleId: rule.id }, + 'Failed to escalate compliance flag' + ); + } + } + } + } catch (err) { + logger.error({ err }, 'Compliance escalation check failed'); + } + } + + // Run immediately, then on interval + checkAndEscalate(); + setInterval(checkAndEscalate, INTERVAL_MS); +} diff --git a/apps/api/src/routes/compliance.ts b/apps/api/src/routes/compliance.ts index 73513e5..45d8f72 100644 --- a/apps/api/src/routes/compliance.ts +++ b/apps/api/src/routes/compliance.ts @@ -194,7 +194,16 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => { return; } - const { resolution_status, severity, importer_id, assigned_to, case_status, priority, 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; @@ -329,10 +338,12 @@ complianceRouter.post('/flags/:id/assign', async (req: Request, res: Response) = 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); + 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; @@ -357,10 +368,7 @@ complianceRouter.post('/flags/:id/status', async (req: Request, res: Response) = } params.push(req.params.id); - await pool.query( - `UPDATE compliance_flags SET ${updates.join(', ')} WHERE id = $${idx}`, - params - ); + await pool.query(`UPDATE compliance_flags SET ${updates.join(', ')} WHERE id = $${idx}`, params); res.json({ success: true }); }); @@ -451,3 +459,167 @@ complianceRouter.get('/reports/:id/download', async (req: Request, res: Response const url = `/dev/reports/${key}`; res.json({ url, expiresInSeconds: 900 }); }); + +// GET /api/v1/compliance/escalation-rules — list escalation rules +complianceRouter.get('/escalation-rules', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const rules = await pool.query( + `SELECT id, surety_id, age_threshold_hours, escalation_target_role, + escalation_target_user_id, is_active, created_at, updated_at + FROM compliance_escalation_rules + WHERE surety_id = $1 + ORDER BY age_threshold_hours ASC`, + [user.id] + ); + + res.json({ rules: rules.rows }); +}); + +// POST /api/v1/compliance/escalation-rules — create escalation rule +complianceRouter.post('/escalation-rules', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + age_threshold_hours: z.number().int().positive().max(720), // max 30 days + escalation_target_role: z.enum(['senior_admin', 'specific_user']), + escalation_target_user_id: z.string().uuid().optional(), + }) + .safeParse(req.body); + + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error }); + return; + } + + const { age_threshold_hours, escalation_target_role, escalation_target_user_id } = parse.data; + + // Validate specific_user requires escalation_target_user_id + if (escalation_target_role === 'specific_user' && !escalation_target_user_id) { + res.status(400).json({ error: 'escalation_target_user_id required for specific_user role' }); + return; + } + + const result = await pool.query( + `INSERT INTO compliance_escalation_rules + (surety_id, age_threshold_hours, escalation_target_role, escalation_target_user_id, is_active) + VALUES ($1, $2, $3, $4, TRUE) + RETURNING id, surety_id, age_threshold_hours, escalation_target_role, + escalation_target_user_id, is_active, created_at, updated_at`, + [user.id, age_threshold_hours, escalation_target_role, escalation_target_user_id ?? null] + ); + + res.status(201).json({ rule: result.rows[0] }); +}); + +// PUT /api/v1/compliance/escalation-rules/:id — update escalation rule +complianceRouter.put('/escalation-rules/:id', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + age_threshold_hours: z.number().int().positive().max(720).optional(), + escalation_target_role: z.enum(['senior_admin', 'specific_user']).optional(), + escalation_target_user_id: z.string().uuid().optional().nullable(), + is_active: z.boolean().optional(), + }) + .safeParse(req.body); + + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error }); + return; + } + + // Check rule exists and belongs to this surety + const existing = await pool.query( + `SELECT id FROM compliance_escalation_rules WHERE id = $1 AND surety_id = $2`, + [req.params.id, user.id] + ); + + if (!existing.rowCount) { + res.status(404).json({ error: 'escalation rule not found' }); + return; + } + + const updates: string[] = ['updated_at = now()']; + const params: unknown[] = []; + let idx = 1; + + if (parse.data.age_threshold_hours !== undefined) { + updates.push(`age_threshold_hours = $${idx++}`); + params.push(parse.data.age_threshold_hours); + } + if (parse.data.escalation_target_role !== undefined) { + updates.push(`escalation_target_role = $${idx++}`); + params.push(parse.data.escalation_target_role); + } + if (parse.data.escalation_target_user_id !== undefined) { + updates.push(`escalation_target_user_id = $${idx++}`); + params.push(parse.data.escalation_target_user_id); + } + if (parse.data.is_active !== undefined) { + updates.push(`is_active = $${idx++}`); + params.push(parse.data.is_active); + } + + params.push(req.params.id); + const result = await pool.query( + `UPDATE compliance_escalation_rules + SET ${updates.join(', ')} + WHERE id = $${idx} + RETURNING id, surety_id, age_threshold_hours, escalation_target_role, + escalation_target_user_id, is_active, created_at, updated_at`, + params + ); + + res.json({ rule: result.rows[0] }); +}); + +// DELETE /api/v1/compliance/escalation-rules/:id — delete escalation rule +complianceRouter.delete('/escalation-rules/:id', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const result = await pool.query( + `DELETE FROM compliance_escalation_rules + WHERE id = $1 AND surety_id = $2 + RETURNING id`, + [req.params.id, user.id] + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'escalation rule not found' }); + return; + } + + res.json({ success: true }); +}); + +// GET /api/v1/compliance/escalation-history/:flagId — get escalation history for a flag +complianceRouter.get('/escalation-history/:flagId', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + // Verify flag belongs to this surety + const flag = await pool.query( + `SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`, + [req.params.flagId, user.id] + ); + + if (!flag.rowCount) { + res.status(404).json({ error: 'flag not found' }); + return; + } + + const history = await pool.query( + `SELECT eh.id, eh.flag_id, eh.escalation_rule_id, eh.previous_assignee, + eh.new_assignee, eh.escalated_at, + er.age_threshold_hours + FROM compliance_escalation_history eh + JOIN compliance_escalation_rules er ON er.id = eh.escalation_rule_id + WHERE eh.flag_id = $1 + ORDER BY eh.escalated_at DESC`, + [req.params.flagId] + ); + + res.json({ history: history.rows }); +}); diff --git a/apps/api/src/routes/surety-marketplace.ts b/apps/api/src/routes/surety-marketplace.ts new file mode 100644 index 0000000..f2b5880 --- /dev/null +++ b/apps/api/src/routes/surety-marketplace.ts @@ -0,0 +1,272 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; + +/** + * Surety Partner Rate Comparison Marketplace (#1036) + * + * Routes for importers to browse and compare surety partners before onboarding. + * Admin routes for managing marketplace partner listings. + */ +export const suretyMarketplaceRouter = Router(); + +// Public/importer routes +suretyMarketplaceRouter.use(authMiddleware); +suretyMarketplaceRouter.use(privacyReacceptanceGate); +suretyMarketplaceRouter.use(tosReacceptanceGate); + +// GET /api/v1/surety-marketplace — list available surety partners +suretyMarketplaceRouter.get('/', async (req: Request, res: Response) => { + const query = z + .object({ + min_collateral_ratio: z.coerce.number().positive().optional(), + max_collateral_ratio: z.coerce.number().positive().optional(), + coverage_type: z.enum(['continuous', 'single_entry', 'term']).optional(), + state_licensed: z.string().length(2).optional(), // State code filter + sort: z.enum(['collateral_ratio', 'rating', 'name']).default('collateral_ratio'), + }) + .safeParse(req.query); + + if (!query.success) { + res.status(400).json({ error: 'invalid query parameters' }); + return; + } + + const { min_collateral_ratio, max_collateral_ratio, coverage_type, state_licensed, sort } = + query.data; + + const conditions: string[] = ['sp.is_active = TRUE', 'sp.is_published = TRUE']; + const params: unknown[] = []; + let idx = 1; + + if (min_collateral_ratio) { + conditions.push(`sp.collateral_ratio >= $${idx++}`); + params.push(min_collateral_ratio); + } + if (max_collateral_ratio) { + conditions.push(`sp.collateral_ratio <= $${idx++}`); + params.push(max_collateral_ratio); + } + if (coverage_type) { + conditions.push(`$${idx++} = ANY(sp.coverage_types)`); + params.push(coverage_type); + } + if (state_licensed) { + conditions.push( + `EXISTS ( + SELECT 1 FROM surety_state_licenses ssl + WHERE ssl.surety_id = sp.surety_id AND ssl.state_code = $${idx++} + )` + ); + params.push(state_licensed); + } + + const where = conditions.join(' AND '); + let orderBy = 'sp.collateral_ratio ASC'; + if (sort === 'rating') { + orderBy = 'sp.am_best_rating DESC NULLS LAST, sp.collateral_ratio ASC'; + } else if (sort === 'name') { + orderBy = 'sp.company_name ASC'; + } + + const partners = await pool.query( + `SELECT sp.id, sp.company_name, sp.collateral_ratio, sp.coverage_types, + sp.am_best_rating, sp.naic_number, sp.base_premium_rate, + sp.description, sp.min_bond_amount, sp.max_bond_amount, + sp.states_licensed_count, sp.created_at, sp.updated_at + FROM surety_marketplace_partners sp + WHERE ${where} + ORDER BY ${orderBy} + LIMIT 50`, + params + ); + + res.json({ + partners: partners.rows, + disclaimer: + 'This marketplace provides informational rate comparisons only. Rates shown are indicative and not binding quotes. Contact the surety partner directly for official quotes and terms.', + }); +}); + +// GET /api/v1/surety-marketplace/:id — get details for a specific partner +suretyMarketplaceRouter.get('/:id', async (req: Request, res: Response) => { + const partner = await pool.query( + `SELECT sp.id, sp.surety_id, sp.company_name, sp.collateral_ratio, + sp.coverage_types, sp.am_best_rating, sp.naic_number, + sp.base_premium_rate, sp.description, sp.min_bond_amount, + sp.max_bond_amount, sp.states_licensed_count, + sp.contact_email, sp.contact_phone, sp.website_url, + sp.stellar_contract_address, sp.created_at, sp.updated_at, + (SELECT array_agg(state_code ORDER BY state_code) + FROM surety_state_licenses + WHERE surety_id = sp.surety_id) AS licensed_states + FROM surety_marketplace_partners sp + WHERE sp.id = $1 AND sp.is_active = TRUE AND sp.is_published = TRUE`, + [req.params.id] + ); + + if (!partner.rowCount) { + res.status(404).json({ error: 'surety partner not found' }); + return; + } + + res.json({ + partner: partner.rows[0], + disclaimer: + 'This information is provided for comparison purposes. Verify all details and obtain an official quote directly from the surety partner.', + }); +}); + +// Admin routes for managing marketplace listings +const adminMarketplaceRouter = Router(); +adminMarketplaceRouter.use(authMiddleware); +adminMarketplaceRouter.use(privacyReacceptanceGate); +adminMarketplaceRouter.use(tosReacceptanceGate); +adminMarketplaceRouter.use(requireRole('surety_admin')); + +// POST /api/v1/surety-marketplace/admin — create or update marketplace listing +adminMarketplaceRouter.post('/admin', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + collateral_ratio: z.number().positive(), + coverage_types: z.array(z.enum(['continuous', 'single_entry', 'term'])), + base_premium_rate: z.number().positive(), + description: z.string().max(500), + min_bond_amount: z.number().positive(), + max_bond_amount: z.number().positive(), + contact_email: z.string().email(), + contact_phone: z.string().optional(), + website_url: z.string().url().optional(), + stellar_contract_address: z.string().optional(), + }) + .safeParse(req.body); + + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error }); + return; + } + + // Get surety company details from verification record + const verification = await pool.query( + `SELECT company_name, naic_number, am_best_rating + FROM surety_license_verifications + WHERE user_id = $1 AND status = 'verified'`, + [user.id] + ); + + if (!verification.rowCount) { + res.status(403).json({ error: 'surety license not verified' }); + return; + } + + const { company_name, naic_number, am_best_rating } = verification.rows[0] as { + company_name: string; + naic_number: string; + am_best_rating: string; + }; + + // Count licensed states + const statesCount = await pool.query<{ count: string }>( + `SELECT COUNT(*) as count FROM surety_state_licenses WHERE surety_id = $1`, + [user.id] + ); + + const result = await pool.query( + `INSERT INTO surety_marketplace_partners + (surety_id, company_name, naic_number, am_best_rating, collateral_ratio, + coverage_types, base_premium_rate, description, min_bond_amount, max_bond_amount, + states_licensed_count, contact_email, contact_phone, website_url, + stellar_contract_address, is_active, is_published) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, TRUE, FALSE) + ON CONFLICT (surety_id) DO UPDATE SET + collateral_ratio = EXCLUDED.collateral_ratio, + coverage_types = EXCLUDED.coverage_types, + base_premium_rate = EXCLUDED.base_premium_rate, + description = EXCLUDED.description, + min_bond_amount = EXCLUDED.min_bond_amount, + max_bond_amount = EXCLUDED.max_bond_amount, + states_licensed_count = EXCLUDED.states_licensed_count, + contact_email = EXCLUDED.contact_email, + contact_phone = EXCLUDED.contact_phone, + website_url = EXCLUDED.website_url, + stellar_contract_address = EXCLUDED.stellar_contract_address, + updated_at = now() + RETURNING id, surety_id, company_name, collateral_ratio, is_published`, + [ + user.id, + company_name, + naic_number, + am_best_rating, + parse.data.collateral_ratio, + parse.data.coverage_types, + parse.data.base_premium_rate, + parse.data.description, + parse.data.min_bond_amount, + parse.data.max_bond_amount, + parseInt(statesCount.rows[0]?.count ?? '0', 10), + parse.data.contact_email, + parse.data.contact_phone ?? null, + parse.data.website_url ?? null, + parse.data.stellar_contract_address ?? null, + ] + ); + + res.status(201).json({ + partner: result.rows[0], + message: 'Marketplace listing created. Submit for review to publish.', + }); +}); + +// GET /api/v1/surety-marketplace/admin/my-listing — get own marketplace listing +adminMarketplaceRouter.get('/admin/my-listing', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const listing = await pool.query( + `SELECT * FROM surety_marketplace_partners WHERE surety_id = $1`, + [user.id] + ); + + if (!listing.rowCount) { + res.status(404).json({ error: 'no marketplace listing found' }); + return; + } + + res.json({ listing: listing.rows[0] }); +}); + +// PUT /api/v1/surety-marketplace/admin/publish — toggle publish status +adminMarketplaceRouter.put('/admin/publish', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z.object({ is_published: z.boolean() }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid input' }); + return; + } + + const result = await pool.query( + `UPDATE surety_marketplace_partners + SET is_published = $1, updated_at = now() + WHERE surety_id = $2 + RETURNING id, is_published`, + [parse.data.is_published, user.id] + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'no marketplace listing found' }); + return; + } + + res.json({ success: true, listing: result.rows[0] }); +}); + +export { adminMarketplaceRouter }; diff --git a/docs/compliance-escalation-and-marketplace.md b/docs/compliance-escalation-and-marketplace.md new file mode 100644 index 0000000..ded962a --- /dev/null +++ b/docs/compliance-escalation-and-marketplace.md @@ -0,0 +1,192 @@ +# Compliance Escalation and Surety Marketplace + +This document describes the implementation of two new features: + +- **Issue #1034**: Automated Escalation Rules for Unresolved Compliance Flags +- **Issue #1036**: Surety Partner Rate Comparison Marketplace + +## Compliance Escalation (Issue #1034) + +### Overview + +Automated escalation rules ensure that compliance flags do not sit unresolved beyond configured time thresholds. When a flag exceeds its age threshold, it is automatically escalated with increased priority and reassigned to a senior admin. + +### Database Schema + +**compliance_escalation_rules** + +- `id`: Unique identifier +- `surety_id`: Reference to the surety admin who owns the rule +- `age_threshold_hours`: Time threshold after which flags are escalated +- `escalation_target_role`: Either 'senior_admin' or 'specific_user' +- `escalation_target_user_id`: Specific user to escalate to (if role is 'specific_user') +- `is_active`: Whether the rule is currently active + +**compliance_escalation_history** + +- Tracks every escalation event for audit purposes +- Records previous and new assignees, escalation rule used, and timestamp + +### API Endpoints + +**GET /compliance/escalation-rules** + +- List all escalation rules for the authenticated surety admin + +**POST /compliance/escalation-rules** + +- Create a new escalation rule +- Body: `{ age_threshold_hours, escalation_target_role, escalation_target_user_id? }` + +**PUT /compliance/escalation-rules/:id** + +- Update an existing escalation rule + +**DELETE /compliance/escalation-rules/:id** + +- Delete an escalation rule + +**GET /compliance/escalation-history/:flagId** + +- View escalation history for a specific compliance flag + +### Automated Job + +The `startComplianceEscalation()` job runs every 15 minutes: + +1. Fetches all active escalation rules +2. Identifies flags exceeding age thresholds that haven't been escalated +3. Updates flag status to 'escalated' and bumps priority +4. Reassigns to the configured target admin +5. Records escalation in history table +6. Sends notification to the escalation target + +### Priority Escalation Logic + +- `low` → `medium` +- `medium` → `high` +- `high` → `critical` +- `critical` → remains `critical` + +## Surety Marketplace (Issue #1036) + +### Overview + +The marketplace allows importers to browse and compare surety partners before onboarding. Surety partners can publish their rate terms and importers can filter/compare by collateral ratio, coverage type, and state licensing. + +### Database Schema + +**surety_marketplace_partners** + +- `id`: Unique identifier +- `surety_id`: Reference to the surety admin (unique) +- `company_name`, `naic_number`, `am_best_rating`: Company details +- `collateral_ratio`: Required collateral percentage +- `coverage_types`: Array of supported bond types +- `base_premium_rate`: Base premium rate +- `description`: Marketing description +- `min_bond_amount`, `max_bond_amount`: Bond amount range +- `states_licensed_count`: Number of states licensed in +- `contact_email`, `contact_phone`, `website_url`: Contact information +- `stellar_contract_address`: On-chain contract address +- `is_active`, `is_published`: Visibility controls + +### API Endpoints + +#### Public/Importer Endpoints + +**GET /surety-marketplace** + +- List available surety partners +- Query params: `min_collateral_ratio`, `max_collateral_ratio`, `coverage_type`, `state_licensed`, `sort` +- Returns partners with disclaimer about informational nature + +**GET /surety-marketplace/:id** + +- Get detailed information for a specific partner +- Includes contact details and licensed states + +#### Admin Endpoints + +**POST /surety-marketplace/admin** + +- Create or update own marketplace listing +- Automatically pulls company details from license verification +- Body: `{ collateral_ratio, coverage_types, base_premium_rate, description, ... }` + +**GET /surety-marketplace/admin/my-listing** + +- View own marketplace listing + +**PUT /surety-marketplace/admin/publish** + +- Toggle publish status +- Body: `{ is_published: boolean }` + +### Integration with Onboarding + +When an importer selects a surety partner from the marketplace: + +1. The partner's `stellar_contract_address` is used +2. The existing `register_importer` flow is followed +3. The importer onboards with that specific surety instance + +### Disclaimers + +The marketplace includes clear disclaimers that: + +- Rates are informational and not binding quotes +- Importers should contact sureties directly for official quotes +- Information should be verified before making decisions + +## Testing + +### Escalation Rules + +```bash +# Create an escalation rule +curl -X POST http://localhost:3001/compliance/escalation-rules \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"age_threshold_hours": 72, "escalation_target_role": "senior_admin"}' + +# List rules +curl http://localhost:3001/compliance/escalation-rules \ + -H "Authorization: Bearer " +``` + +### Marketplace + +```bash +# Browse marketplace +curl http://localhost:3001/surety-marketplace?sort=collateral_ratio \ + -H "Authorization: Bearer " + +# Create listing (surety admin) +curl -X POST http://localhost:3001/surety-marketplace/admin \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "collateral_ratio": 1.25, + "coverage_types": ["continuous"], + "base_premium_rate": 0.0125, + "description": "Competitive rates for importers", + "min_bond_amount": 50000, + "max_bond_amount": 10000000, + "contact_email": "quotes@surety.com" + }' +``` + +## Migration + +Run the SQL migration to create the necessary tables: + +```bash +psql $DATABASE_URL < apps/api/migrations/009_compliance_escalation_and_marketplace.sql +``` + +## Monitoring + +- Escalation job logs escalation events with `compliance_escalation` log entries +- Check `compliance_escalation_history` table for audit trail +- Monitor notifications table for escalation alerts sent to admins