From 959907d63bf127223e05099c9c9cd12dd5b955e6 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:31:35 -0400 Subject: [PATCH] feat(18-03): add GET /api/phishing/campaigns/[id] nested detail - requirePermission('phishing','read') gate (ACCESS-01) - UUID-validated id (400 on malformed), 404 when campaign absent - bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern) - requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column) - messages.subject pulled from headers->>'subject' JSONB (no subject column) - classifications included in shape (Phase 19 stub, expected empty) --- app/api/phishing/campaigns/[id]/route.ts | 173 +++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/route.ts diff --git a/app/api/phishing/campaigns/[id]/route.ts b/app/api/phishing/campaigns/[id]/route.ts new file mode 100644 index 0000000..29808e3 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/route.ts @@ -0,0 +1,173 @@ +/** + * GET /api/phishing/campaigns/[id] + * Returns a single campaign with nested linked reports, messages, + * indicators, and classification history. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +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 CampaignRow { + id: string; + campaign_key: string | null; + group_method: string | null; + first_seen_at: string | null; + last_seen_at: string | null; + report_count: number; + status: string; + created_at: string; + updated_at: string; +} + +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; + report_id: string; + message_id: string | null; + subject: string | null; +} + +interface IndicatorRow { + id: string; + message_id: string; + indicator_type: string; + value: string; + metadata: unknown; +} + +interface ClassificationRow { + id: string; + verdict: string; + confidence: string | null; + summary: string | null; + created_at: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requirePermission('phishing', 'read'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed UUID would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } + + try { + const campaignRes = await postgresClient.query( + `SELECT id::text, campaign_key, group_method, first_seen_at::text, + last_seen_at::text, report_count, status, created_at::text, updated_at::text + FROM campaigns WHERE id = $1`, + [id] + ); + const campaign = campaignRes.rows[0]; + if (!campaign) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + // Bulk-fetch linked reports (+ join contacts for requester email — campaigns + // has no recipients column, must be derived via this join). + const reportsRes = 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.campaign_id = $1 + ORDER BY r.created_at ASC`, + [id] + ); + 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. + const messagesRes = reportIds.length + ? await postgresClient.query( + `SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject + FROM messages WHERE report_id = ANY($1::uuid[])`, + [reportIds] + ) + : { rows: [] as MessageRow[] }; + const messageIds = messagesRes.rows.map((m) => m.id); + + // Bulk-fetch indicators keyed by the message-id array. + const indicatorsRes = messageIds.length + ? await postgresClient.query( + `SELECT id::text, message_id::text, indicator_type, value, metadata + FROM indicators WHERE message_id = ANY($1::uuid[])`, + [messageIds] + ) + : { rows: [] as IndicatorRow[] }; + + // Classifications (Phase 19 stub — likely empty this phase, still + // included in the response shape per CAMP-03). + const classificationsRes = await postgresClient.query( + `SELECT id::text, verdict, confidence, summary, created_at::text + FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC`, + [id] + ); + + return NextResponse.json({ + id: campaign.id, + campaignKey: campaign.campaign_key, + groupMethod: campaign.group_method, + firstSeenAt: campaign.first_seen_at, + lastSeenAt: campaign.last_seen_at, + reportCount: campaign.report_count, + 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, + })), + }); + } catch (err) { + console.error('[PHISHING-CAMPAIGN-DETAIL] Failed to load campaign', id, err); + return NextResponse.json( + { error: 'Failed to load campaign', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}