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
4 changes: 4 additions & 0 deletions apps/api/src/constants/notification-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const NOTIFICATION_KINDS = {
KYC_REJECTED: 'kyc_rejected',
TARIFF_SPIKE: 'tariff_spike',
EVENT_RECEIVED: 'event_received',
UPGRADE_PROPOSED: 'upgrade_proposed',
UPGRADE_APPROVED: 'upgrade_approved',
UPGRADE_CANCELLED: 'upgrade_cancelled',
SLA_BREACH: 'sla_breach',
} as const;

export type NotificationKind = (typeof NOTIFICATION_KINDS)[keyof typeof NOTIFICATION_KINDS];
8 changes: 8 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ import { kycRouter } from './routes/kyc.js';
import { startComplianceReportScheduler } from './jobs/compliance-report.js';
import { startImporterMetricsScheduler } from './jobs/refresh-importer-metrics.js';
import { startContractEventsPartitionScheduler } from './jobs/ensure-contract-events-partitions.js';
import { startSlaBreachChecker } from './jobs/sla-breach-checker.js';
import { suretyLicenseRouter } from './routes/surety-license.js';
import { regulatoryRouter } from './routes/regulatory.js';
import { healthRouter } from './routes/health.js';
import { httpLogger, logger } from './lib/logger.js';
import { notificationsRouter } from './routes/notifications.js';
import { upgradeSubscriptionsRouter } from './routes/upgrade-subscriptions.js';
import { bondAnnotationsRouter } from './routes/bond-annotations.js';
import { slaRouter } from './routes/sla.js';

const app = express();
app.use(httpLogger);
Expand Down Expand Up @@ -319,6 +323,9 @@ app.use('/account', tosRouter);
app.use('/privacy', privacyRouter);
app.use('/surety-license', suretyLicenseRouter);
app.use('/notifications', notificationsRouter);
app.use('/upgrade-subscriptions', upgradeSubscriptionsRouter);
app.use('/bond-annotations', bondAnnotationsRouter);
app.use('/sla', slaRouter);
app.use('/api/v1/regulatory', regulatoryRouter);
app.use('/bonds', bondWebhookRouter); // unauthenticated DocuSign webhook
app.use('/api', bondSignaturesRouter); // authenticated bond signature routes
Expand All @@ -345,6 +352,7 @@ async function start() {
startComplianceReportScheduler();
startImporterMetricsScheduler();
startContractEventsPartitionScheduler();
startSlaBreachChecker();
app.listen(env.PORT, () => {
logger.info(
{
Expand Down
66 changes: 66 additions & 0 deletions apps/api/src/jobs/sla-breach-checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { pool } from '../db.js';
import { logger } from '../lib/logger.js';

/**
* Periodic job that checks for SLA breaches. Runs every 5 minutes.
*
* Issue #1042: Configurable business hours and SLA tracking.
* Marks items as breached when their deadline has passed and they haven't been resolved.
*/
export function startSlaBreachChecker(): void {
const INTERVAL_MS = 5 * 60 * 1000;

async function checkForBreaches(): Promise<void> {
try {
// Mark unresolved items whose deadline has passed as breached
const result = await pool.query(
`UPDATE sla_tracking
SET is_breached = TRUE, updated_at = now()
WHERE resolved_at IS NULL
AND is_breached = FALSE
AND deadline < now()
RETURNING id, item_type, item_id, deadline`
);

if (result.rowCount && result.rowCount > 0) {
logger.info({ count: result.rowCount }, 'SLA breaches detected');

// Create notifications for breached items
for (const row of result.rows) {
await notifySlaBreach(row.surety_id, row.item_type, row.item_id, row.deadline);
}
}
} catch (err) {
logger.error({ err }, 'SLA breach check failed');
}
}

// Run immediately, then on interval
checkForBreaches();
setInterval(checkForBreaches, INTERVAL_MS);
}

async function notifySlaBreach(
suretyId: string,
itemType: string,
itemId: string,
deadline: Date
): Promise<void> {
try {
// Import createNotification dynamically to avoid circular deps
const { createNotification } = await import('../db.js');

// Get the surety admin user for this tenant
const result = await pool.query(
`SELECT id FROM users WHERE id = $1 AND role = 'surety_admin'`,
[suretyId]
);

if (result.rowCount) {
const message = `SLA breach: ${itemType.replace(/_/g, ' ')} ${itemId.slice(0, 8)}… has exceeded its ${deadline.toISOString().split('T')[0]} deadline.`;
await createNotification(suretyId, 'sla_breach', message);
}
} catch (err) {
logger.error({ err, suretyId, itemType, itemId }, 'failed to send SLA breach notification');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// 0006_stakeholder_subscriptions_annotations_sla.ts
// Adds tables for:
// - Issue #1047: Stakeholder notification subscriptions for contract upgrade proposals
// - Issue #1046: Admin/Importer annotation notes on bond timeline events
// - Issue #1042: Configurable business hours and SLA tracking for admin response times
//
// Migration: 0006_stakeholder_subscriptions_annotations_sla
// Date: 2026-08-28

import type { PoolClient } from 'pg';

export const up = async (client: PoolClient): Promise<void> => {
// ── #1047: Stakeholder notification subscriptions ──────────────────────────

await client.query(`
CREATE TABLE IF NOT EXISTS upgrade_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
surety_id UUID NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, surety_id)
)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_upgrade_subscriptions_user_surety
ON upgrade_subscriptions (user_id, surety_id, is_active)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_upgrade_subscriptions_surety_active
ON upgrade_subscriptions (surety_id, is_active)
`);

await client.query(`
CREATE TABLE IF NOT EXISTS upgrade_notification_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id BIGINT NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('proposed', 'approved', 'cancelled')),
proposer TEXT NOT NULL,
approval_count INTEGER NOT NULL DEFAULT 0,
wasm_hash TEXT,
notification_sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_upgrade_notification_history_proposal
ON upgrade_notification_history (proposal_id, created_at DESC)
`);

// ── #1046: Bond timeline event annotations ────────────────────────────────

await client.query(`
CREATE TABLE IF NOT EXISTS bond_timeline_annotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL,
importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE,
surety_id UUID NOT NULL,
author_id UUID NOT NULL REFERENCES users(id),
author_role TEXT NOT NULL CHECK (author_role IN ('importer', 'surety_admin')),
note TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_bond_timeline_annotations_event
ON bond_timeline_annotations (event_id, created_at DESC)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_bond_timeline_annotations_importer
ON bond_timeline_annotations (importer_id, surety_id, created_at DESC)
`);

// ── #1042: Configurable business hours and SLA tracking ───────────────────

await client.query(`
CREATE TABLE IF NOT EXISTS business_hours_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
surety_id UUID NOT NULL UNIQUE,
timezone TEXT NOT NULL DEFAULT 'America/New_York',
monday_start TIME,
monday_end TIME,
tuesday_start TIME,
tuesday_end TIME,
wednesday_start TIME,
wednesday_end TIME,
thursday_start TIME,
thursday_end TIME,
friday_start TIME,
friday_end TIME,
saturday_start TIME,
saturday_end TIME,
sunday_start TIME,
sunday_end TIME,
holidays JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);

await client.query(`
CREATE TABLE IF NOT EXISTS sla_targets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
surety_id UUID NOT NULL,
item_type TEXT NOT NULL CHECK (item_type IN ('compliance_flag', 'dispute', 'ticket')),
target_hours NUMERIC NOT NULL DEFAULT 24,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(surety_id, item_type)
)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_sla_targets_surety_type
ON sla_targets (surety_id, item_type)
`);

await client.query(`
CREATE TABLE IF NOT EXISTS sla_tracking (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
surety_id UUID NOT NULL,
item_type TEXT NOT NULL CHECK (item_type IN ('compliance_flag', 'dispute', 'ticket')),
item_id UUID NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deadline TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
is_breached BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_sla_tracking_surety_type_status
ON sla_tracking (surety_id, item_type, is_breached, resolved_at)
`);

await client.query(`
CREATE INDEX IF NOT EXISTS idx_sla_tracking_item
ON sla_tracking (item_id, item_type)
`);
};

export const down = async (client: PoolClient): Promise<void> => {
await client.query(`DROP TABLE IF EXISTS sla_tracking`);
await client.query(`DROP TABLE IF EXISTS sla_targets`);
await client.query(`DROP TABLE IF EXISTS business_hours_config`);
await client.query(`DROP TABLE IF EXISTS bond_timeline_annotations`);
await client.query(`DROP TABLE IF EXISTS upgrade_notification_history`);
await client.query(`DROP TABLE IF EXISTS upgrade_subscriptions`);
};
Loading