diff --git a/app/api/phishing/reports/[report_id]/route.ts b/app/api/phishing/reports/[report_id]/route.ts new file mode 100644 index 0000000..c04bb9d --- /dev/null +++ b/app/api/phishing/reports/[report_id]/route.ts @@ -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 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( + `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( + `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( + `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 } + ); + } +} diff --git a/app/phishing/tickets/[ticketId]/page.tsx b/app/phishing/tickets/[ticketId]/page.tsx new file mode 100644 index 0000000..b424ee4 --- /dev/null +++ b/app/phishing/tickets/[ticketId]/page.tsx @@ -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('loading'); + const [resolution, setResolution] = useState(null); + const [campaignId, setCampaignId] = useState(null); + const [campaignDetail, setCampaignDetail] = useState(null); + const [standaloneReport, setStandaloneReport] = useState(null); + const [error, setError] = useState(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 ( +
+ + + {state === 'loading' && ( +
+ + + + +
+ )} + + {state === 'not-triaged' && ( + + )} + + {state === 'ungrouped' && standaloneReport && ( +
+ + + Grouping in progress — this report hasn't been linked to a campaign yet. The + evidence below is from this report only; classification and remediation will appear + once grouping completes. + + + +
+ )} + + {state === 'error' && ( + + + Couldn't load this campaign. {error} — try reloading the page. + + + + )} + + {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 ( +
+ +
+ + +
+
+ ); + } + + return ( +
+ + +
+ + +
+
+ ); + })()} +
+ ); +}