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:
parent
bced734717
commit
3761312f93
2 changed files with 485 additions and 0 deletions
146
app/api/phishing/reports/[report_id]/route.ts
Normal file
146
app/api/phishing/reports/[report_id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue