diff --git a/apps/api/src/constants/notification-kinds.ts b/apps/api/src/constants/notification-kinds.ts index 495a0a3..a1516c1 100644 --- a/apps/api/src/constants/notification-kinds.ts +++ b/apps/api/src/constants/notification-kinds.ts @@ -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]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8b356ef..4248ae1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); @@ -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 @@ -345,6 +352,7 @@ async function start() { startComplianceReportScheduler(); startImporterMetricsScheduler(); startContractEventsPartitionScheduler(); + startSlaBreachChecker(); app.listen(env.PORT, () => { logger.info( { diff --git a/apps/api/src/jobs/sla-breach-checker.ts b/apps/api/src/jobs/sla-breach-checker.ts new file mode 100644 index 0000000..3f9fd94 --- /dev/null +++ b/apps/api/src/jobs/sla-breach-checker.ts @@ -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 { + 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 { + 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'); + } +} diff --git a/apps/api/src/migrations/0006_stakeholder_subscriptions_annotations_sla.ts b/apps/api/src/migrations/0006_stakeholder_subscriptions_annotations_sla.ts new file mode 100644 index 0000000..7cfc311 --- /dev/null +++ b/apps/api/src/migrations/0006_stakeholder_subscriptions_annotations_sla.ts @@ -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 => { + // ── #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 => { + 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`); +}; diff --git a/apps/api/src/routes/bond-annotations.ts b/apps/api/src/routes/bond-annotations.ts new file mode 100644 index 0000000..721afd8 --- /dev/null +++ b/apps/api/src/routes/bond-annotations.ts @@ -0,0 +1,201 @@ +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'; + +export const bondAnnotationsRouter = Router(); +bondAnnotationsRouter.use(authMiddleware); +bondAnnotationsRouter.use(privacyReacceptanceGate); +bondAnnotationsRouter.use(tosReacceptanceGate); + +// POST /bond-annotations — add an annotation to a timeline event +bondAnnotationsRouter.post('/', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + event_id: z.string().uuid(), + importer_id: z.string().uuid(), + note: z.string().min(1).max(2000), + }) + .safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'event_id, importer_id, and note are required' }); + return; + } + + const { event_id, importer_id, note } = parse.data; + + // Determine role and verify access + const isSuretyAdmin = user.role === 'surety_admin'; + const isImporter = user.role === 'importer'; + + if (!isSuretyAdmin && !isImporter) { + res.status(403).json({ error: 'unauthorized' }); + return; + } + + // 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] + ); + if (!importer.rowCount) { + res.status(403).json({ error: 'unauthorized' }); + return; + } + } + + // Get surety_id from the importer + 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' }); + return; + } + + const authorRole = isSuretyAdmin ? 'surety_admin' : 'importer'; + + const result = await pool.query( + `INSERT INTO bond_timeline_annotations (event_id, importer_id, surety_id, author_id, author_role, note) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, event_id, importer_id, author_id, author_role, note, created_at, updated_at`, + [event_id, importer_id, suretyId, user.id, authorRole, note] + ); + + res.status(201).json({ annotation: result.rows[0] }); +}); + +// GET /bond-annotations/:importerId — list annotations for an importer +bondAnnotationsRouter.get('/:importerId', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const importerId = String(req.params.importerId); + + // Verify access + const isSuretyAdmin = user.role === 'surety_admin'; + const isImporter = user.role === 'importer'; + + if (!isSuretyAdmin && !isImporter) { + res.status(403).json({ error: 'unauthorized' }); + return; + } + + if (isImporter) { + 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; + } + } + + const result = await pool.query( + `SELECT id, event_id, importer_id, author_id, author_role, note, created_at, updated_at + FROM bond_timeline_annotations + WHERE importer_id = $1 + ORDER BY created_at DESC`, + [importerId] + ); + + res.json({ annotations: result.rows }); +}); + +// 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( + `SELECT id, event_id, importer_id, author_id, author_role, note, created_at, updated_at + FROM bond_timeline_annotations + WHERE event_id = $1 + ORDER BY created_at DESC`, + [eventId] + ); + + res.json({ annotations: result.rows }); +}); + +// PATCH /bond-annotations/:id — edit an annotation (author or surety_admin only) +bondAnnotationsRouter.patch('/:id', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z.object({ note: z.string().min(1).max(2000) }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'note is required' }); + return; + } + + const annotationId = String(req.params.id); + + // Check ownership or admin role + const existing = await pool.query( + `SELECT id, author_id FROM bond_timeline_annotations WHERE id = $1`, + [annotationId] + ); + + if (!existing.rowCount) { + res.status(404).json({ error: 'annotation not found' }); + return; + } + + const annotation = existing.rows[0]; + const isAuthor = annotation.author_id === user.id; + const isAdmin = user.role === 'surety_admin'; + + if (!isAuthor && !isAdmin) { + res.status(403).json({ error: 'unauthorized' }); + return; + } + + const result = await pool.query( + `UPDATE bond_timeline_annotations + 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] + ); + + res.json({ annotation: result.rows[0] }); +}); + +// DELETE /bond-annotations/:id — delete an annotation (author or surety_admin only) +bondAnnotationsRouter.delete('/:id', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const annotationId = String(req.params.id); + + const existing = await pool.query( + `SELECT id, author_id FROM bond_timeline_annotations WHERE id = $1`, + [annotationId] + ); + + if (!existing.rowCount) { + res.status(404).json({ error: 'annotation not found' }); + return; + } + + const annotation = existing.rows[0]; + const isAuthor = annotation.author_id === user.id; + const isAdmin = user.role === 'surety_admin'; + + if (!isAuthor && !isAdmin) { + res.status(403).json({ error: 'unauthorized' }); + return; + } + + await pool.query(`DELETE FROM bond_timeline_annotations WHERE id = $1`, [annotationId]); + + res.json({ success: true }); +}); diff --git a/apps/api/src/routes/sla.ts b/apps/api/src/routes/sla.ts new file mode 100644 index 0000000..c2ec2d7 --- /dev/null +++ b/apps/api/src/routes/sla.ts @@ -0,0 +1,377 @@ +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'; + +export const slaRouter = Router(); +slaRouter.use(authMiddleware); +slaRouter.use(privacyReacceptanceGate); +slaRouter.use(tosReacceptanceGate); +slaRouter.use(requireRole('surety_admin')); + +// ── Business Hours Configuration ────────────────────────────────────────── + +const DayTimeSchema = z + .string() + .regex(/^\d{2}:\d{2}$/) + .nullable() + .optional(); + +// POST /sla/business-hours — configure business hours for a surety tenant +slaRouter.post('/business-hours', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + timezone: z.string().default('America/New_York'), + monday_start: DayTimeSchema, + monday_end: DayTimeSchema, + tuesday_start: DayTimeSchema, + tuesday_end: DayTimeSchema, + wednesday_start: DayTimeSchema, + wednesday_end: DayTimeSchema, + thursday_start: DayTimeSchema, + thursday_end: DayTimeSchema, + friday_start: DayTimeSchema, + friday_end: DayTimeSchema, + saturday_start: DayTimeSchema, + saturday_end: DayTimeSchema, + sunday_start: DayTimeSchema, + sunday_end: DayTimeSchema, + holidays: z.array(z.string()).default([]), + }) + .safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid configuration', details: parse.error.issues }); + return; + } + + const data = parse.data; + const suretyId = user.id; + + const existing = await pool.query( + `SELECT id FROM business_hours_config WHERE surety_id = $1`, + [suretyId] + ); + + if (existing.rowCount) { + await pool.query( + `UPDATE business_hours_config + SET timezone = $1, + monday_start = $2, monday_end = $3, + tuesday_start = $4, tuesday_end = $5, + wednesday_start = $6, wednesday_end = $7, + thursday_start = $8, thursday_end = $9, + friday_start = $10, friday_end = $11, + saturday_start = $12, saturday_end = $13, + sunday_start = $14, sunday_end = $15, + holidays = $16, updated_at = now() + 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, + JSON.stringify(data.holidays), + suretyId, + ] + ); + } else { + await pool.query( + `INSERT INTO business_hours_config + (surety_id, timezone, + monday_start, monday_end, tuesday_start, tuesday_end, + wednesday_start, wednesday_end, thursday_start, thursday_end, + friday_start, friday_end, saturday_start, saturday_end, + 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, + JSON.stringify(data.holidays), + ] + ); + } + + res.json({ success: true }); +}); + +// GET /sla/business-hours — get current business hours configuration +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] + ); + + 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, + holidays: [], + }); + return; + } + + res.json(result.rows[0]); +}); + +// ── SLA Targets ─────────────────────────────────────────────────────────── + +// POST /sla/targets — configure SLA target hours per item type +slaRouter.post('/targets', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + item_type: z.enum(['compliance_flag', 'dispute', 'ticket']), + target_hours: z.number().positive(), + }) + .safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'item_type and target_hours are required' }); + return; + } + + const { item_type, target_hours } = parse.data; + + const existing = await pool.query( + `SELECT id FROM sla_targets WHERE surety_id = $1 AND item_type = $2`, + [user.id, item_type] + ); + + if (existing.rowCount) { + await pool.query( + `UPDATE sla_targets SET target_hours = $1, updated_at = now() + WHERE surety_id = $2 AND item_type = $3`, + [target_hours, user.id, item_type] + ); + } else { + await pool.query( + `INSERT INTO sla_targets (surety_id, item_type, target_hours) + VALUES ($1, $2, $3)`, + [user.id, item_type, target_hours] + ); + } + + res.json({ success: true }); +}); + +// GET /sla/targets — list all SLA targets for this surety +slaRouter.get('/targets', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const result = await pool.query( + `SELECT id, item_type, target_hours, created_at, updated_at + FROM sla_targets + WHERE surety_id = $1 + ORDER BY item_type`, + [user.id] + ); + + res.json({ targets: result.rows }); +}); + +// ── SLA Tracking & Breach Flagging ──────────────────────────────────────── + +// GET /sla/tracking — list SLA items with breach status +slaRouter.get('/tracking', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const query = z + .object({ + item_type: z.enum(['compliance_flag', 'dispute', 'ticket']).optional(), + breached_only: z.coerce.boolean().default(false), + resolved: z.coerce.boolean().optional(), + limit: z.coerce.number().int().positive().max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + }) + .safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: 'invalid query parameters' }); + return; + } + + const { item_type, breached_only, resolved, limit, offset } = query.data; + const conditions: string[] = ['st.surety_id = $1']; + const params: unknown[] = [user.id]; + let idx = 2; + + if (item_type) { + conditions.push(`st.item_type = $${idx++}`); + params.push(item_type); + } + if (breached_only) { + conditions.push(`st.is_breached = TRUE`); + } + if (resolved !== undefined) { + if (resolved) { + conditions.push(`st.resolved_at IS NOT NULL`); + } else { + conditions.push(`st.resolved_at IS NULL`); + } + } + + const where = conditions.join(' AND '); + + const [rows, total] = await Promise.all([ + pool.query( + `SELECT st.id, st.item_type, st.item_id, st.started_at, st.deadline, + st.resolved_at, st.is_breached, st.created_at + FROM sla_tracking st + WHERE ${where} + ORDER BY st.is_breached DESC, st.deadline ASC + LIMIT $${idx} OFFSET $${idx + 1}`, + [...params, limit, offset] + ), + pool.query<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM sla_tracking st WHERE ${where}`, + params + ), + ]); + + res.json({ + items: rows.rows, + total: parseInt(total.rows[0]?.cnt ?? '0', 10), + limit, + offset, + }); +}); + +// GET /sla/compliance-rate — SLA compliance rate over a date range +slaRouter.get('/compliance-rate', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + item_type: z.enum(['compliance_flag', 'dispute', 'ticket']).optional(), + }) + .safeParse(req.query); + if (!parse.success) { + res.status(400).json({ error: 'invalid query parameters' }); + return; + } + + const { from, to, item_type } = parse.data; + const conditions: string[] = ['surety_id = $1']; + const params: unknown[] = [user.id]; + let idx = 2; + + if (from) { + conditions.push(`created_at >= $${idx++}`); + params.push(from); + } + if (to) { + conditions.push(`created_at <= $${idx++}`); + params.push(to); + } + if (item_type) { + conditions.push(`item_type = $${idx++}`); + params.push(item_type); + } + + const where = conditions.join(' AND '); + + const result = await pool.query<{ total: string; breaches: string }>( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE is_breached = TRUE) AS breaches + FROM sla_tracking + WHERE ${where}`, + params + ); + + const row = result.rows[0]; + const total = parseInt(row?.total ?? '0', 10); + const breaches = parseInt(row?.breaches ?? '0', 10); + const complianceRate = total > 0 ? ((total - breaches) / total) * 100 : 100; + + res.json({ total, breaches, complianceRate: Math.round(complianceRate * 100) / 100 }); +}); + +// POST /sla/tracking — start SLA tracking for a new item +slaRouter.post('/tracking', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z + .object({ + item_type: z.enum(['compliance_flag', 'dispute', 'ticket']), + item_id: z.string().uuid(), + }) + .safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'item_type and item_id are required' }); + return; + } + + const { item_type, item_id } = parse.data; + + // Get the SLA target for this item type + const targetResult = await pool.query( + `SELECT target_hours FROM sla_targets WHERE surety_id = $1 AND item_type = $2`, + [user.id, item_type] + ); + + if (!targetResult.rowCount) { + res.status(400).json({ error: 'no SLA target configured for this item type' }); + return; + } + + const targetHours = parseFloat(targetResult.rows[0].target_hours); + const deadline = new Date(Date.now() + targetHours * 3600 * 1000); + + const result = await pool.query( + `INSERT INTO sla_tracking (surety_id, item_type, item_id, deadline) + VALUES ($1, $2, $3, $4) + RETURNING id, item_type, item_id, started_at, deadline, is_breached, created_at`, + [user.id, item_type, item_id, deadline.toISOString()] + ); + + res.status(201).json({ tracking: result.rows[0] }); +}); + +// PATCH /sla/tracking/:id/resolve — mark an SLA item as resolved +slaRouter.patch('/tracking/:id/resolve', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const trackingId = String(req.params.id); + + const result = await pool.query( + `UPDATE sla_tracking + SET resolved_at = now(), updated_at = now() + WHERE id = $1 AND surety_id = $2 AND resolved_at IS NULL + RETURNING id, item_type, item_id, started_at, deadline, resolved_at, is_breached`, + [trackingId, user.id] + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'tracking entry not found or already resolved' }); + return; + } + + res.json({ tracking: result.rows[0] }); +}); diff --git a/apps/api/src/routes/upgrade-subscriptions.ts b/apps/api/src/routes/upgrade-subscriptions.ts new file mode 100644 index 0000000..2dd21f4 --- /dev/null +++ b/apps/api/src/routes/upgrade-subscriptions.ts @@ -0,0 +1,102 @@ +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'; + +export const upgradeSubscriptionsRouter = Router(); +upgradeSubscriptionsRouter.use(authMiddleware); +upgradeSubscriptionsRouter.use(privacyReacceptanceGate); +upgradeSubscriptionsRouter.use(tosReacceptanceGate); + +// POST /upgrade-subscriptions — subscribe to upgrade proposal notifications +upgradeSubscriptionsRouter.post('/', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = z.object({ surety_id: z.string().uuid() }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'surety_id is required' }); + return; + } + + const { surety_id } = parse.data; + + const existing = await pool.query( + `SELECT id, is_active FROM upgrade_subscriptions + WHERE user_id = $1 AND surety_id = $2`, + [user.id, surety_id] + ); + + if (existing.rowCount && existing.rows[0]?.is_active) { + res.json({ success: true, message: 'already subscribed' }); + return; + } + + if (existing.rowCount && !existing.rows[0]?.is_active) { + await pool.query( + `UPDATE upgrade_subscriptions SET is_active = TRUE, updated_at = now() + WHERE user_id = $1 AND surety_id = $2`, + [user.id, surety_id] + ); + } else { + await pool.query( + `INSERT INTO upgrade_subscriptions (user_id, surety_id) + VALUES ($1, $2)`, + [user.id, surety_id] + ); + } + + res.json({ success: true }); +}); + +// DELETE /upgrade-subscriptions/:suretyId — unsubscribe from upgrade proposal notifications +upgradeSubscriptionsRouter.delete('/:suretyId', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const suretyId = String(req.params.suretyId); + + await pool.query( + `UPDATE upgrade_subscriptions SET is_active = FALSE, updated_at = now() + WHERE user_id = $1 AND surety_id = $2`, + [user.id, suretyId] + ); + + res.json({ success: true }); +}); + +// GET /upgrade-subscriptions — list user's active subscriptions +upgradeSubscriptionsRouter.get('/', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const result = await pool.query( + `SELECT id, surety_id, is_active, created_at, updated_at + FROM upgrade_subscriptions + WHERE user_id = $1 + ORDER BY created_at DESC`, + [user.id] + ); + + res.json({ subscriptions: result.rows }); +}); + +// GET /upgrade-subscriptions/history — list notification history for subscribed sureties +upgradeSubscriptionsRouter.get('/history', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const result = await pool.query( + `SELECT unh.id, unh.proposal_id, unh.event_type, unh.proposer, + unh.approval_count, unh.wasm_hash, unh.created_at + FROM upgrade_notification_history unh + JOIN upgrade_subscriptions us ON us.surety_id = unh.surety_id + WHERE us.user_id = $1 AND us.is_active = TRUE + ORDER BY unh.created_at DESC + LIMIT 50`, + [user.id] + ); + + res.json({ history: result.rows }); +}); diff --git a/apps/api/src/services/upgrade-notifications.ts b/apps/api/src/services/upgrade-notifications.ts new file mode 100644 index 0000000..6ed5559 --- /dev/null +++ b/apps/api/src/services/upgrade-notifications.ts @@ -0,0 +1,68 @@ +import { pool, createNotification } from '../db.js'; +import { NOTIFICATION_KINDS } from '../constants/notification-kinds.js'; +import { logger } from '../lib/logger.js'; + +/** + * Records an upgrade proposal event and notifies all active subscribers + * for the given surety tenant. Called when propose_upgrade, approve_upgrade, + * or cancel_upgrade events occur. + * + * Issue #1047: Stakeholder notification subscriptions for upgrade proposals. + */ +export async function notifyUpgradeSubscribers(params: { + suretyId: string; + proposalId: number; + eventType: 'proposed' | 'approved' | 'cancelled'; + proposer: string; + approvalCount: number; + wasmHash?: string; +}): Promise { + const { suretyId, proposalId, eventType, proposer, approvalCount, wasmHash } = params; + + // Record the event in history + await pool.query( + `INSERT INTO upgrade_notification_history (proposal_id, event_type, proposer, approval_count, wasm_hash) + VALUES ($1, $2, $3, $4, $5)`, + [proposalId, eventType, proposer, approvalCount, wasmHash ?? null] + ); + + // Find all active subscribers for this surety + const subscribers = await pool.query( + `SELECT user_id FROM upgrade_subscriptions + WHERE surety_id = $1 AND is_active = TRUE`, + [suretyId] + ); + + const kindMap = { + proposed: NOTIFICATION_KINDS.UPGRADE_PROPOSED, + approved: NOTIFICATION_KINDS.UPGRADE_APPROVED, + cancelled: NOTIFICATION_KINDS.UPGRADE_CANCELLED, + } as const; + + const kind = kindMap[eventType]; + const message = buildUpgradeMessage(eventType, proposalId, proposer, approvalCount); + + for (const row of subscribers.rows) { + try { + await createNotification(row.user_id, kind, message); + } catch (err) { + logger.error({ err, userId: row.user_id, proposalId }, 'failed to send upgrade notification'); + } + } +} + +function buildUpgradeMessage( + eventType: 'proposed' | 'approved' | 'cancelled', + proposalId: number, + proposer: string, + approvalCount: number +): string { + switch (eventType) { + case 'proposed': + return `Contract upgrade proposal #${proposalId} has been raised by ${proposer.slice(0, 8)}…`; + case 'approved': + return `Contract upgrade proposal #${proposalId} has been approved (${approvalCount} approval${approvalCount !== 1 ? 's' : ''}).`; + case 'cancelled': + return `Contract upgrade proposal #${proposalId} has been cancelled by ${proposer.slice(0, 8)}…`; + } +} diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx index a183fcc..b24e631 100644 --- a/apps/web/app/app/page.tsx +++ b/apps/web/app/app/page.tsx @@ -327,7 +327,7 @@ function ImporterDashboard() { - +

diff --git a/apps/web/components/BondTimeline.tsx b/apps/web/components/BondTimeline.tsx index 0b92f35..1d8e3e4 100644 --- a/apps/web/components/BondTimeline.tsx +++ b/apps/web/components/BondTimeline.tsx @@ -1,10 +1,25 @@ 'use client'; -import { useState } from 'react'; -import { type ContractEvent } from '@/lib/api'; +import { useState, useEffect, useRef } from 'react'; +import { type ContractEvent, type BondAnnotation, api } from '@/lib/api'; -export function BondTimeline({ events }: { events: ContractEvent[] }) { +export function BondTimeline({ + events, + importerId, + userRole, +}: { + events: ContractEvent[]; + importerId: string; + userRole?: 'importer' | 'surety_admin'; +}) { const [currentMonth, setCurrentMonth] = useState(new Date()); + const [selectedEvent, setSelectedEvent] = useState(null); + const [annotations, setAnnotations] = useState([]); + const [newNote, setNewNote] = useState(''); + const [loadingAnnotations, setLoadingAnnotations] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editNote, setEditNote] = useState(''); + const popoverRef = useRef(null); const year = currentMonth.getFullYear(); const month = currentMonth.getMonth(); @@ -51,8 +66,76 @@ export function BondTimeline({ events }: { events: ContractEvent[] }) { const prevMonth = () => setCurrentMonth(new Date(year, month - 1)); const nextMonth = () => setCurrentMonth(new Date(year, month + 1)); + const handleEventClick = async (event: ContractEvent) => { + if (selectedEvent?.id === event.id) { + setSelectedEvent(null); + setAnnotations([]); + return; + } + setSelectedEvent(event); + setLoadingAnnotations(true); + try { + const result = await api.getEventAnnotations(event.id); + setAnnotations(result.annotations); + } catch { + setAnnotations([]); + } finally { + setLoadingAnnotations(false); + } + }; + + const handleAddAnnotation = async () => { + if (!newNote.trim() || !selectedEvent) return; + try { + const result = await api.addAnnotation({ + event_id: selectedEvent.id, + importer_id: importerId, + note: newNote.trim(), + }); + setAnnotations([result.annotation, ...annotations]); + setNewNote(''); + } catch { + // silently fail + } + }; + + const handleUpdateAnnotation = async (id: string) => { + if (!editNote.trim()) return; + try { + const result = await api.updateAnnotation(id, editNote.trim()); + setAnnotations(annotations.map((a) => (a.id === id ? result.annotation : a))); + setEditingId(null); + setEditNote(''); + } catch { + // silently fail + } + }; + + const handleDeleteAnnotation = async (id: string) => { + try { + await api.deleteAnnotation(id); + setAnnotations(annotations.filter((a) => a.id !== id)); + } catch { + // silently fail + } + }; + + // Close popover on outside click + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setSelectedEvent(null); + setAnnotations([]); + } + } + if (selectedEvent) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [selectedEvent]); + return ( -
+

Bond Timeline

@@ -98,13 +181,14 @@ export function BondTimeline({ events }: { events: ContractEvent[] }) {

{date.getDate()}

{dayEvents.slice(0, 2).map((e) => ( -
handleEventClick(e)} + className={`w-full text-left px-1 py-0.5 rounded text-xs truncate font-medium ${getEventColor(e.kind)} hover:opacity-80 cursor-pointer ${selectedEvent?.id === e.id ? 'ring-1 ring-accent' : ''}`} + title={`${e.kind} — click to view annotations`} > {e.kind.split('_').pop()} -
+ ))} {dayEvents.length > 2 && (
+{dayEvents.length - 2} more
@@ -117,6 +201,107 @@ export function BondTimeline({ events }: { events: ContractEvent[] }) {
+ {/* Annotation popover */} + {selectedEvent && ( +
+
+

{selectedEvent.kind.replace(/_/g, ' ')}

+ +
+

+ {new Date(selectedEvent.createdAt).toLocaleString()} +

+ + {/* Annotations list */} +
+ {loadingAnnotations ? ( +

Loading annotations…

+ ) : annotations.length === 0 ? ( +

No annotations yet

+ ) : ( + annotations.map((ann) => ( +
+
+ + {ann.authorRole === 'surety_admin' ? 'Admin' : 'Importer'} ·{' '} + {new Date(ann.createdAt).toLocaleDateString()} + + {userRole === ann.authorRole && ( +
+ + +
+ )} +
+ {editingId === ann.id ? ( +
+ setEditNote(e.target.value)} + className="flex-1 rounded border border-border px-1 py-0.5 text-xs" + /> + +
+ ) : ( +

{ann.note}

+ )} +
+ )) + )} +
+ + {/* Add annotation */} + {userRole && ( +
+ setNewNote(e.target.value)} + placeholder="Add a note…" + className="flex-1 rounded border border-border px-2 py-1 text-xs" + onKeyDown={(e) => e.key === 'Enter' && handleAddAnnotation()} + /> + +
+ )} +
+ )} + {events.length > 0 && (

Legend:

diff --git a/apps/web/components/DepositWizard.tsx b/apps/web/components/DepositWizard.tsx index a780bff..9720f18 100644 --- a/apps/web/components/DepositWizard.tsx +++ b/apps/web/components/DepositWizard.tsx @@ -10,11 +10,13 @@ export function DepositWizard({ importerId, bucket, onDone, + onCancel, setError, }: { importerId: string; bucket: 'collateral' | 'reserve'; onDone: () => Promise; + onCancel?: () => void; setError: (e: FormattedError | string | null) => void; }) { const [step, setStep] = useState('amount'); @@ -22,6 +24,13 @@ export function DepositWizard({ const [txHash, setTxHash] = useState(null); const [busy, setBusy] = useState(false); + function handleCancel() { + setStep('amount'); + setXlm('50'); + setTxHash(null); + onCancel?.(); + } + async function handleDeposit() { setBusy(true); setError(null); @@ -72,10 +81,10 @@ export function DepositWizard({
- + {!busy && ( + + )}