Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
109 changes: 109 additions & 0 deletions apps/api/migrations/009_compliance_escalation_and_marketplace.sql
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -365,6 +369,7 @@ async function start() {
startSlaBreachChecker();
startApiKeyUsagePruneScheduler();
startOnboardingDripScheduler();
startComplianceEscalation();
app.listen(env.PORT, () => {
logger.info(
{
Expand Down
134 changes: 134 additions & 0 deletions apps/api/src/jobs/compliance-escalation.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
Loading