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,35 @@
-- Migration 008: Data retention policies, case management queue, portfolio view, and tariff forecasting

-- #1031: Data retention policies per data category
CREATE TABLE IF NOT EXISTS data_retention_policies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE,
data_category TEXT NOT NULL CHECK (data_category IN ('documents', 'logs', 'events', 'tariff_uploads')),
retention_days INTEGER NOT NULL CHECK (retention_days > 0),
is_regulatory_required BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (importer_id, data_category)
);

CREATE INDEX IF NOT EXISTS idx_data_retention_policies_importer ON data_retention_policies(importer_id);

-- #1029: Case management queue for compliance flags
ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS assigned_to UUID REFERENCES users(id);
ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS priority TEXT DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical'));
ALTER TABLE compliance_flags ADD COLUMN IF NOT EXISTS case_status TEXT DEFAULT 'new' CHECK (case_status IN ('new', 'investigating', 'escalated', 'resolved'));

-- Case notes table
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),
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);

-- Index for queue filtering
CREATE INDEX IF NOT EXISTS idx_compliance_flags_assignment ON compliance_flags(assigned_to, case_status, priority);
CREATE INDEX IF NOT EXISTS idx_compliance_flags_status ON compliance_flags(case_status, priority);
156 changes: 152 additions & 4 deletions apps/api/src/routes/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => {
resolution_status: z.enum(['open', 'resolved']).optional(),
severity: z.enum(['low', 'medium', 'high', 'critical']).optional(),
importer_id: z.string().uuid().optional(),
assigned_to: z.string().uuid().optional(),
case_status: z.enum(['new', 'investigating', 'escalated', 'resolved']).optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
})
Expand All @@ -191,7 +194,7 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => {
return;
}

const { resolution_status, severity, importer_id, 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;
Expand All @@ -208,18 +211,34 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => {
conditions.push(`cf.importer_id = $${idx++}`);
params.push(importer_id);
}
if (assigned_to) {
conditions.push(`cf.assigned_to = $${idx++}`);
params.push(assigned_to);
}
if (case_status) {
conditions.push(`cf.case_status = $${idx++}`);
params.push(case_status);
}
if (priority) {
conditions.push(`cf.priority = $${idx++}`);
params.push(priority);
}

const where = conditions.join(' AND ');

const [flags, total] = await Promise.all([
pool.query(
`SELECT cf.id, cf.importer_id, i.legal_name AS importer_name,
cf.flag_type, cf.severity, cf.description,
cf.resolution_status, cf.resolution_note, cf.resolved_at, cf.created_at
cf.resolution_status, cf.resolution_note, cf.resolved_at, cf.created_at,
cf.assigned_to, cf.priority, cf.case_status,
EXTRACT(EPOCH FROM (now() - cf.created_at)) / 3600 AS age_hours
FROM compliance_flags cf
JOIN importers i ON i.id = cf.importer_id
WHERE ${where}
ORDER BY cf.created_at DESC
ORDER BY
CASE cf.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
cf.created_at DESC
LIMIT $${idx} OFFSET $${idx + 1}`,
[...params, limit, offset]
),
Expand All @@ -229,8 +248,15 @@ complianceRouter.get('/flags', async (req: Request, res: Response) => {
),
]);

// SLA indicator: flag cases open beyond 72 hours (3 days)
const slaThresholdHours = 72;
const flagsWithSla = flags.rows.map((flag) => ({
...flag,
slaBreached: Number(flag.age_hours) > slaThresholdHours,
}));

res.json({
flags: flags.rows,
flags: flagsWithSla,
total: parseInt(total.rows[0]?.cnt ?? '0', 10),
limit,
offset,
Expand Down Expand Up @@ -270,6 +296,128 @@ complianceRouter.post('/flags/:id/resolve', async (req: Request, res: Response)
res.json({ success: true });
});

// POST /api/v1/compliance/flags/:id/assign — assign a flag to an admin user
complianceRouter.post('/flags/:id/assign', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;

const parse = z.object({ assigned_to: z.string().uuid() }).safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: 'assigned_to is required' });
return;
}

const flag = await pool.query(
`SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`,
[req.params.id, user.id]
);
if (!flag.rowCount) {
res.status(404).json({ error: 'flag not found' });
return;
}

await pool.query(
`UPDATE compliance_flags
SET assigned_to = $1, case_status = 'investigating', updated_at = now()
WHERE id = $2`,
[parse.data.assigned_to, req.params.id]
);

res.json({ success: true });
});

// POST /api/v1/compliance/flags/:id/status — update case status
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);
if (!parse.success) {
res.status(400).json({ error: 'invalid input' });
return;
}

const flag = await pool.query(
`SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`,
[req.params.id, user.id]
);
if (!flag.rowCount) {
res.status(404).json({ error: 'flag not found' });
return;
}

const updates = ['case_status = $1', 'updated_at = now()'];
const params: unknown[] = [parse.data.case_status];
let idx = 2;

if (parse.data.priority) {
updates.push(`priority = $${idx++}`);
params.push(parse.data.priority);
}

params.push(req.params.id);
await pool.query(
`UPDATE compliance_flags SET ${updates.join(', ')} WHERE id = $${idx}`,
params
);

res.json({ success: true });
});

// POST /api/v1/compliance/flags/:id/notes — add a case note
complianceRouter.post('/flags/:id/notes', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;

const parse = z.object({ content: z.string().min(1) }).safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: 'content is required' });
return;
}

const flag = await pool.query(
`SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`,
[req.params.id, user.id]
);
if (!flag.rowCount) {
res.status(404).json({ error: 'flag not found' });
return;
}

const result = await pool.query(
`INSERT INTO compliance_case_notes (flag_id, author_id, content)
VALUES ($1, $2, $3)
RETURNING id, flag_id, author_id, content, created_at`,
[req.params.id, user.id, parse.data.content]
);

res.status(201).json({ note: result.rows[0] });
});

// GET /api/v1/compliance/flags/:id/notes — list case notes
complianceRouter.get('/flags/:id/notes', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;

const flag = await pool.query(
`SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2`,
[req.params.id, user.id]
);
if (!flag.rowCount) {
res.status(404).json({ error: 'flag not found' });
return;
}

const result = await pool.query(
`SELECT id, flag_id, author_id, content, created_at
FROM compliance_case_notes
WHERE flag_id = $1
ORDER BY created_at DESC`,
[req.params.id]
);

res.json({ notes: result.rows });
});

// GET /api/v1/compliance/reports — list available compliance reports for this surety
complianceRouter.get('/reports', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;
Expand Down
96 changes: 95 additions & 1 deletion apps/api/src/routes/erasure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { pool, createDataErasureRequest } from '../db.js';
import { pool, createDataErasureRequest, logAudit } from '../db.js';
import {
authMiddleware,
privacyReacceptanceGate,
Expand Down Expand Up @@ -79,3 +79,97 @@ erasureRouter.get('/account/erasure-request/:requestId', async (req: Request, re
errorMessage: request.error_message,
});
});

// ── #1031: Data retention policy configuration ──────────────────────────────

const RetentionPolicySchema = z.object({
dataCategory: z.enum(['documents', 'logs', 'events', 'tariff_uploads']),
retentionDays: z.number().int().positive(),
});

// GET /api/v1/erasure/retention-policies — list retention policies for the importer
erasureRouter.get('/retention-policies', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;

const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]);
const importerId = importerResult.rows[0]?.id ?? null;

if (!importerId) {
res.status(404).json({ error: 'importer not found' });
return;
}

const result = await pool.query(
'SELECT id, data_category, retention_days, is_regulatory_required, created_at, updated_at FROM data_retention_policies WHERE importer_id = $1 ORDER BY data_category',
[importerId]
);

res.json({ policies: result.rows });
});

// POST /api/v1/erasure/retention-policies — set retention policy for a data category
erasureRouter.post('/retention-policies', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;

const parse = RetentionPolicySchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: 'invalid input', details: parse.error.issues });
return;
}

const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]);
const importerId = importerResult.rows[0]?.id ?? null;

if (!importerId) {
res.status(404).json({ error: 'importer not found' });
return;
}

const { dataCategory, retentionDays } = parse.data;

// Regulatory-required categories are excluded from configurable retention
const regulatoryCategories = ['documents']; // KYC documents have regulatory retention
const isRegulatoryRequired = regulatoryCategories.includes(dataCategory);

const result = await pool.query(
`INSERT INTO data_retention_policies (importer_id, data_category, retention_days, is_regulatory_required)
VALUES ($1, $2, $3, $4)
ON CONFLICT (importer_id, data_category)
DO UPDATE SET retention_days = $3, updated_at = now()
RETURNING id, data_category, retention_days, is_regulatory_required, created_at, updated_at`,
[importerId, dataCategory, retentionDays, isRegulatoryRequired]
);

await logAudit(user.id, 'set_retention_policy', importerId, {
dataCategory,
retentionDays,
isRegulatoryRequired,
});

res.json({ policy: result.rows[0] });
});

// DELETE /api/v1/erasure/retention-policies/:id — delete a retention policy
erasureRouter.delete('/retention-policies/:id', async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;

const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]);
const importerId = importerResult.rows[0]?.id ?? null;

if (!importerId) {
res.status(404).json({ error: 'importer not found' });
return;
}

const result = await pool.query(
'DELETE FROM data_retention_policies WHERE id = $1 AND importer_id = $2 AND is_regulatory_required = FALSE',
[req.params.id, importerId]
);

if (result.rowCount === 0) {
res.status(404).json({ error: 'policy not found or is regulatory-required' });
return;
}

res.json({ success: true });
});
Loading