feat(22-06): ticket-scoped LiveLink review page (REVIEW-01,05,06)

- app/phishing/tickets/[ticketId]/page.tsx: resolves ticket->campaign via
  the plan-02 resolver route, drives a loading/not-triaged/ungrouped/ready/
  error state machine, branches ready into grouped-but-unclassified
  (Classify CTA, no ClassificationCard/ActionAreaCard) vs. classified (all
  four cards with explicit props), refetches after every action (D-04),
  session-only auth (no token/query-param scheme)
- app/api/phishing/reports/[report_id]/route.ts (new, additive): thin
  report-scoped evidence + fresh blast-radius lookup for the D-08
  ungrouped-report state, which has no campaignId to key the existing
  campaign-detail route on — added as a Rule 2 dependency since the plan's
  own D-08 truth ("standalone-report notice + evidence") has no other data
  source
This commit is contained in:
lorentz 2026-07-16 14:58:39 -04:00
parent bced734717
commit 3761312f93
2 changed files with 485 additions and 0 deletions

View file

@ -0,0 +1,146 @@
/**
* GET /api/phishing/reports/{report_id}
* Standalone report evidence lookup for the D-08 "ungrouped report" state
* on the ticket-scoped review page (Phase 22 plan 06): a `reports` row
* that hasn't been linked to a campaign yet (`campaign_id IS NULL`, the
* narrow race window before Phase 18's grouping runs). Returns just
* enough the report's own linked message evidence + a fresh
* blast-radius lookup to render <EvidenceCard> standalone, without a
* campaign wrapper. Mirrors the bulk-fetch idiom in
* app/api/phishing/campaigns/[id]/route.ts, scoped to a single report_id
* instead of a campaign_id.
*/
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';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface ReportRow {
id: string;
ticket_id: string;
ticket_number: string | null;
company_name: string | null;
title: string | null;
created_at: string;
requester_email: string | null;
}
interface MessageRow {
id: string;
message_id: string | null;
headers: unknown;
urls: unknown;
attachments: unknown;
body_preview: string | null;
}
interface IndicatorRow {
id: string;
message_id: string;
indicator_type: string;
value: string;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ report_id: string }> }
) {
const { error } = await requirePermission('phishing', 'read');
if (error) return error;
const { report_id } = await params;
if (!UUID_RE.test(report_id)) {
return NextResponse.json({ error: 'Invalid report id' }, { status: 400 });
}
try {
const reportRes = await postgresClient.query<ReportRow>(
`SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_name,
r.title, r.created_at::text,
c.email_address AS requester_email
FROM reports r
LEFT JOIN contacts c ON c.id = r.requester_contact_id
WHERE r.id = $1`,
[report_id]
);
const report = reportRes.rows[0];
if (!report) {
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
}
const messagesRes = await postgresClient.query<MessageRow>(
`SELECT id::text, message_id, headers, urls, attachments, body_preview
FROM messages WHERE report_id = $1 ORDER BY created_at ASC`,
[report_id]
);
const messageIds = messagesRes.rows.map((m) => m.id);
const indicatorsRes = messageIds.length
? await postgresClient.query<IndicatorRow>(
`SELECT id::text, message_id::text, indicator_type, value
FROM indicators WHERE message_id = ANY($1::uuid[])`,
[messageIds]
)
: { rows: [] as IndicatorRow[] };
// Evidence shape matches EvidenceMessage (components/phishing/evidence-card.tsx):
// a single ungrouped report has at most one linked message today, but this
// returns an array for shape-compatibility with EvidenceCard's multi-message Select.
const messages = messagesRes.rows.map((m) => ({
id: m.id,
ticketNumber: report.ticket_number,
reportCreatedAt: report.created_at,
headers: m.headers,
urls: m.urls,
attachments: m.attachments,
bodyPreview: m.body_preview ?? '',
}));
// Fresh blast-radius lookup (D-03: never persisted here), same derivation
// as app/api/phishing/campaigns/[id]/route.ts — sender/recipient/subject/
// dateWindow sourced from this report's primary (first) message.
const primaryMessage = messagesRes.rows[0] ?? null;
let blastRadius: BlastRadiusResult;
if (primaryMessage) {
const senderIndicator = indicatorsRes.rows.find(
(i) => i.message_id === primaryMessage.id && i.indicator_type === 'sender'
);
const messageHeaders = (primaryMessage.headers ?? null) as
| { from?: { email?: string | null } | null; subject?: string | null }
| null;
const createdAt = new Date(report.created_at);
blastRadius = await getBlastRadius({
sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '',
recipient: report.requester_email ?? '',
subject: messageHeaders?.subject ?? report.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' };
}
return NextResponse.json({
id: report.id,
ticketId: report.ticket_id,
ticketNumber: report.ticket_number,
companyName: report.company_name,
title: report.title,
createdAt: report.created_at,
requesterEmail: report.requester_email,
messages,
blastRadius,
});
} catch (err) {
console.error('[PHISHING-REPORT-DETAIL] Failed to load report', report_id, err);
return NextResponse.json(
{ error: 'Failed to load report', message: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 }
);
}
}