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 }
);
}
}

View file

@ -0,0 +1,339 @@
'use client';
/**
* Ticket-scoped phishing campaign review page the Autotask LiveLink target
* (REVIEW-01, REVIEW-05, REVIEW-06). Resolves the URL's numeric ticket id to
* its campaign via a two-step fetch, drives a
* loading/not-triaged/ungrouped/ready/error state machine, and composes the
* plan-03/04/05 cards. Authentication is the existing Better Auth session
* only (this route is not in middleware.ts's publicRoutes) no separate
* token/query-param auth scheme.
*/
import { use, useCallback, useEffect, useState } from 'react';
import { SearchX, Sparkles } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
import { EmptyState } from '@/components/ui/empty-state';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { SkeletonCard, SkeletonHeader } from '@/components/ui/skeleton-helpers';
import { toast } from 'sonner';
import { useSession } from '@/lib/auth-client';
import { hasPermission } from '@/lib/permissions';
import { ClassificationCard, type ClassificationCardData } from '@/components/phishing/classification-card';
import { ActionAreaCard, type RemediationActionSummary } from '@/components/phishing/action-area-card';
import {
EvidenceCard,
type EvidenceMessage,
type BlastRadiusResult,
} from '@/components/phishing/evidence-card';
import { TimelineCard, type TimelineEntry } from '@/components/phishing/timeline-card';
type PageState = 'loading' | 'not-triaged' | 'ungrouped' | 'ready' | 'error';
interface TicketCampaignResolution {
found: boolean;
reportId?: string;
campaignId?: string | null;
ticketNumber?: string | null;
}
interface CampaignReport {
id: string;
ticketId: string;
ticketNumber: string | null;
companyName: string | null;
title: string | null;
createdAt: string;
requesterEmail: string | null;
}
interface CampaignMessage {
id: string;
reportId: string;
messageId: string | null;
subject: string | null;
headers: EvidenceMessage['headers'];
urls: EvidenceMessage['urls'];
attachments: EvidenceMessage['attachments'];
bodyPreview: string | null;
}
interface CampaignDetail {
id: string;
campaignKey: string | null;
groupMethod: string | null;
firstSeenAt: string | null;
lastSeenAt: string | null;
reportCount: number;
status: string;
createdAt: string;
updatedAt: string;
reports: CampaignReport[];
messages: CampaignMessage[];
classifications: ClassificationCardData[];
remediationActions: RemediationActionSummary[];
blastRadius: BlastRadiusResult;
timeline: TimelineEntry[];
}
interface StandaloneReport {
id: string;
ticketId: string;
ticketNumber: string | null;
companyName: string | null;
title: string | null;
createdAt: string;
requesterEmail: string | null;
messages: EvidenceMessage[];
blastRadius: BlastRadiusResult;
}
/** `open` reads as "Awaiting triage" per UI-SPEC; everything else is a snake_case -> Title Case fallback. */
function humanizeStatus(status: string): string {
if (status === 'open') return 'Awaiting triage';
return status
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
export default function TicketReviewPage({
params,
}: {
params: Promise<{ ticketId: string }>;
}) {
const { ticketId } = use(params);
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canClassify = hasPermission(role, 'phishing', 'analyze');
const [state, setState] = useState<PageState>('loading');
const [resolution, setResolution] = useState<TicketCampaignResolution | null>(null);
const [campaignId, setCampaignId] = useState<string | null>(null);
const [campaignDetail, setCampaignDetail] = useState<CampaignDetail | null>(null);
const [standaloneReport, setStandaloneReport] = useState<StandaloneReport | null>(null);
const [error, setError] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isClassifying, setIsClassifying] = useState(false);
const load = useCallback(async () => {
setState('loading');
setError(null);
try {
const resolveRes = await fetch(`/api/phishing/tickets/${ticketId}/campaign`);
if (!resolveRes.ok) throw new Error(`Request failed: ${resolveRes.status}`);
const resolved: TicketCampaignResolution = await resolveRes.json();
setResolution(resolved);
if (!resolved.found) {
setState('not-triaged');
return;
}
if (!resolved.campaignId) {
// D-08: report exists but grouping hasn't linked it to a campaign yet.
const reportRes = await fetch(`/api/phishing/reports/${resolved.reportId}`);
if (!reportRes.ok) throw new Error(`Request failed: ${reportRes.status}`);
const report: StandaloneReport = await reportRes.json();
setStandaloneReport(report);
setCampaignId(null);
setCampaignDetail(null);
setState('ungrouped');
return;
}
const detailRes = await fetch(`/api/phishing/campaigns/${resolved.campaignId}`);
if (!detailRes.ok) throw new Error(`Request failed: ${detailRes.status}`);
const detail: CampaignDetail = await detailRes.json();
setCampaignId(resolved.campaignId);
setCampaignDetail(detail);
setStandaloneReport(null);
setState('ready');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setState('error');
}
}, [ticketId]);
useEffect(() => {
void load();
}, [load]);
async function handleAnalyze() {
setIsAnalyzing(true);
try {
const res = await fetch(`/api/phishing/tickets/${ticketId}/analyze`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Analyze failed');
toast.success('Ticket analyzed');
await load();
} catch (err) {
toast.error(`Analyze failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsAnalyzing(false);
}
}
async function handleClassify() {
if (!campaignId) return;
setIsClassifying(true);
try {
const res = await fetch(`/api/phishing/campaigns/${campaignId}/classify`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Classify failed');
toast.success('Campaign classified');
await load();
} catch (err) {
toast.error(`Classify failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsClassifying(false);
}
}
const ticketNumberLabel = resolution?.ticketNumber ?? ticketId;
const description =
state === 'ready' && campaignDetail
? humanizeStatus(campaignDetail.status)
: state === 'not-triaged'
? 'Not yet triaged'
: state === 'ungrouped'
? 'Grouping in progress'
: undefined;
return (
<main className="container mx-auto px-6 py-6 space-y-6">
<PageHeader
title={`Ticket #${ticketNumberLabel}`}
description={description}
breadcrumbs={[
{ label: 'Phishing', href: '/phishing' },
{ label: `Ticket #${ticketNumberLabel}` },
]}
/>
{state === 'loading' && (
<div className="space-y-6">
<SkeletonHeader />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)}
{state === 'not-triaged' && (
<EmptyState
icon={SearchX}
title="Not yet triaged"
description="This ticket hasn't been scanned for phishing indicators yet. Run analysis now to extract evidence and see a classification."
action={{ label: isAnalyzing ? 'Analyzing…' : 'Analyze this ticket', onClick: handleAnalyze }}
/>
)}
{state === 'ungrouped' && standaloneReport && (
<div className="space-y-6">
<Alert>
<AlertDescription>
Grouping in progress this report hasn&apos;t been linked to a campaign yet. The
evidence below is from this report only; classification and remediation will appear
once grouping completes.
</AlertDescription>
</Alert>
<EvidenceCard messages={standaloneReport.messages} blastRadius={standaloneReport.blastRadius} />
</div>
)}
{state === 'error' && (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-4">
<span>Couldn&apos;t load this campaign. {error} try reloading the page.</span>
<Button variant="outline" size="sm" onClick={() => void load()}>
Retry
</Button>
</AlertDescription>
</Alert>
)}
{state === 'ready' &&
campaignDetail &&
campaignId &&
(() => {
// Single latest classification (Warning 3): a freshly-grouped
// campaign returns classifications: [] -> null, the default state
// since detection/grouping never auto-triggers classification.
const classification = campaignDetail.classifications[0] ?? null;
const primaryReport = campaignDetail.reports[0] ?? null;
const primaryMessage = primaryReport
? campaignDetail.messages.find((m) => m.reportId === primaryReport.id) ?? null
: null;
const evidenceMessages: EvidenceMessage[] = campaignDetail.messages.map((m) => {
const report = campaignDetail.reports.find((r) => r.id === m.reportId);
return {
id: m.id,
ticketNumber: report?.ticketNumber ?? null,
reportCreatedAt: report?.createdAt ?? null,
headers: m.headers,
urls: m.urls,
attachments: m.attachments,
bodyPreview: m.bodyPreview ?? '',
};
});
const evidence = {
requesterEmail: primaryReport?.requesterEmail ?? null,
senderEmail: primaryMessage?.headers.from.email ?? null,
senderDomain: primaryMessage?.headers.from.domain ?? null,
messageId: primaryMessage?.messageId ?? null,
};
if (classification == null) {
return (
<div className="space-y-6">
<EmptyState
icon={Sparkles}
title="Not yet classified"
description="This campaign is grouped but hasn't been classified yet. Run classification to generate a verdict and recommended remediation actions."
action={
canClassify
? {
label: isClassifying ? 'Classifying…' : 'Classify this campaign',
onClick: handleClassify,
}
: undefined
}
/>
<div className="grid gap-6 lg:grid-cols-2">
<EvidenceCard messages={evidenceMessages} blastRadius={campaignDetail.blastRadius} />
<TimelineCard timeline={campaignDetail.timeline} />
</div>
</div>
);
}
return (
<div className="space-y-6">
<ClassificationCard
campaignId={campaignId}
classification={classification}
onReclassified={load}
/>
<ActionAreaCard
campaignId={campaignId}
classification={classification}
remediationActions={campaignDetail.remediationActions}
campaignStatus={campaignDetail.status}
campaignUpdatedAt={campaignDetail.updatedAt}
evidence={evidence}
onActionComplete={load}
/>
<div className="grid gap-6 lg:grid-cols-2">
<EvidenceCard messages={evidenceMessages} blastRadius={campaignDetail.blastRadius} />
<TimelineCard timeline={campaignDetail.timeline} />
</div>
</div>
);
})()}
</main>
);
}