feat(22-02): extend campaign detail route with evidence, timeline, blast radius

- Widen messages query to include headers/urls/attachments/body_preview
- Widen classifications query to include reasons/recommendedActions/
  requiresApproval
- Add remediationActions (with completedAt derived from audit_events
  payload.actionId, no completion-timestamp column exists) and
  auditEvents to the response
- Add fresh per-request blastRadius via getBlastRadius(), sender/
  recipient/subject derivation copied from campaign-classifier.ts's
  gatherCampaignEvidence (not triage-note-service's empty-string call)
- Add mergeTimeline()-derived chronological timeline
- All additive — existing fields, UUID_RE guard, and auth gate unchanged
This commit is contained in:
lorentz 2026-07-16 14:42:56 -04:00
parent ca63910562
commit 9e83ec09ee

View file

@ -1,12 +1,16 @@
/**
* GET /api/phishing/campaigns/[id]
* Returns a single campaign with nested linked reports, messages,
* indicators, and classification history.
* indicators, classification history, remediation actions, audit trail,
* a fresh blast-radius lookup, and a merged chronological timeline
* everything the Phase 22 review page needs in one call.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius';
import { mergeTimeline } from '@/lib/services/phishing-timeline';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -37,6 +41,10 @@ interface MessageRow {
report_id: string;
message_id: string | null;
subject: string | null;
headers: unknown;
urls: unknown;
attachments: unknown;
body_preview: string | null;
}
interface IndicatorRow {
@ -52,6 +60,26 @@ interface ClassificationRow {
verdict: string;
confidence: string | null;
summary: string | null;
reasons: unknown;
recommended_actions: unknown;
requires_approval: boolean;
created_at: string;
}
interface RemediationActionRow {
id: string;
action_type: string;
status: string;
params: unknown;
approved_by: string | null;
approved_at: string | null;
}
interface AuditEventRow {
id: string;
actor: string | null;
event_type: string;
payload: unknown;
created_at: string;
}
@ -96,10 +124,13 @@ export async function GET(
const reportIds = reportsRes.rows.map((r) => r.id);
// Bulk-fetch messages keyed by the report-id array. messages has no
// subject column — subject lives in headers JSONB.
// subject column — subject lives in headers JSONB. Widened to also
// return the full evidence shapes (headers/urls/attachments/body_preview)
// for the review page's evidence card.
const messagesRes = reportIds.length
? await postgresClient.query<MessageRow>(
`SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject
`SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject,
headers, urls, attachments, body_preview
FROM messages WHERE report_id = ANY($1::uuid[])`,
[reportIds]
)
@ -115,14 +146,141 @@ export async function GET(
)
: { rows: [] as IndicatorRow[] };
// Classifications (Phase 19 stub — likely empty this phase, still
// included in the response shape per CAMP-03).
// Classifications — widened to include reasons/recommended_actions/
// requires_approval for the review page's classification card.
const classificationsRes = await postgresClient.query<ClassificationRow>(
`SELECT id::text, verdict, confidence, summary, created_at::text
`SELECT id::text, verdict, confidence, summary, reasons, recommended_actions,
requires_approval, created_at::text
FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC`,
[id]
);
// Remediation actions proposed/approved/executed for this campaign.
const remediationRes = await postgresClient.query<RemediationActionRow>(
`SELECT id::text, action_type, status, params, approved_by, approved_at::text
FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`,
[id]
);
// Audit trail — approvals, completions, false-positive markings.
const auditRes = await postgresClient.query<AuditEventRow>(
`SELECT id::text, actor, event_type, payload, created_at::text
FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`,
[id]
);
const reports = reportsRes.rows.map((r) => ({
id: r.id,
ticketId: r.ticket_id,
ticketNumber: r.ticket_number,
companyName: r.company_name,
title: r.title,
createdAt: r.created_at,
requesterEmail: r.requester_email,
}));
const messages = messagesRes.rows.map((m) => ({
id: m.id,
reportId: m.report_id,
messageId: m.message_id,
subject: m.subject,
headers: m.headers,
urls: m.urls,
attachments: m.attachments,
bodyPreview: m.body_preview,
}));
const indicators = indicatorsRes.rows.map((i) => ({
id: i.id,
messageId: i.message_id,
indicatorType: i.indicator_type,
value: i.value,
metadata: i.metadata,
}));
const classifications = classificationsRes.rows.map((c) => ({
id: c.id,
verdict: c.verdict,
confidence: c.confidence,
summary: c.summary,
reasons: c.reasons,
recommendedActions: c.recommended_actions,
requiresApproval: c.requires_approval,
createdAt: c.created_at,
}));
// Derive `completedAt` for each remediation action from the audit trail
// (remediation_actions has no dedicated completion-timestamp column) —
// the 'remediation_completed' audit event's payload.actionId points back
// at the action it completed.
const completedAtByActionId = new Map<string, string>();
for (const event of auditRes.rows) {
if (event.event_type === 'remediation_completed') {
const actionId = (event.payload as { actionId?: string } | null)?.actionId;
if (actionId) completedAtByActionId.set(actionId, event.created_at);
}
}
const remediationActions = remediationRes.rows.map((a) => ({
id: a.id,
actionType: a.action_type,
status: a.status,
params: a.params,
approvedBy: a.approved_by,
approvedAt: a.approved_at,
completedAt: completedAtByActionId.get(a.id) ?? null,
}));
const auditEvents = auditRes.rows.map((e) => ({
id: e.id,
actor: e.actor,
eventType: e.event_type,
payload: e.payload,
createdAt: e.created_at,
}));
// Fresh blast-radius lookup per request (D-03: never persisted here).
// Sender/recipient/subject/dateWindow derivation copied verbatim from
// lib/services/campaign-classifier.ts's gatherCampaignEvidence — NOT
// triage-note-service.ts's empty-string call (Pitfall 3), which would
// produce an unscoped Mimecast fan-out.
const primaryReport = reports[0] ?? null;
let blastRadius: BlastRadiusResult;
if (primaryReport) {
const primaryMessage = messages.find((m) => m.reportId === primaryReport.id) ?? null;
const senderIndicator = indicators.find(
(i) => i.messageId === primaryMessage?.id && i.indicatorType === 'sender'
);
const messageHeaders = (primaryMessage?.headers ?? null) as
| { from?: { email?: string | null } | null }
| null;
const createdAt = new Date(primaryReport.createdAt);
blastRadius = await getBlastRadius({
sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '',
recipient: primaryReport.requesterEmail ?? '',
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
dateWindow: {
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
},
});
} else {
blastRadius = { status: 'unavailable', reason: 'not_configured' };
}
// Merged chronological timeline (reports + classifications + audit
// events), oldest first.
const timeline = mergeTimeline(
reports.map((r) => ({
createdAt: r.createdAt,
reportId: r.id,
ticketNumber: r.ticketNumber,
companyName: r.companyName,
})),
classifications,
auditEvents
);
return NextResponse.json({
id: campaign.id,
campaignKey: campaign.campaign_key,
@ -133,35 +291,14 @@ export async function GET(
status: campaign.status,
createdAt: campaign.created_at,
updatedAt: campaign.updated_at,
reports: reportsRes.rows.map((r) => ({
id: r.id,
ticketId: r.ticket_id,
ticketNumber: r.ticket_number,
companyName: r.company_name,
title: r.title,
createdAt: r.created_at,
requesterEmail: r.requester_email,
})),
messages: messagesRes.rows.map((m) => ({
id: m.id,
reportId: m.report_id,
messageId: m.message_id,
subject: m.subject,
})),
indicators: indicatorsRes.rows.map((i) => ({
id: i.id,
messageId: i.message_id,
indicatorType: i.indicator_type,
value: i.value,
metadata: i.metadata,
})),
classifications: classificationsRes.rows.map((c) => ({
id: c.id,
verdict: c.verdict,
confidence: c.confidence,
summary: c.summary,
createdAt: c.created_at,
})),
reports,
messages,
indicators,
classifications,
remediationActions,
auditEvents,
blastRadius,
timeline,
});
} catch (err) {
console.error('[PHISHING-CAMPAIGN-DETAIL] Failed to load campaign', id, err);