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)
This commit is contained in:
lorentz 2026-07-15 19:31:35 -04:00
parent c852cfee13
commit 959907d63b

View file

@ -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<CampaignRow>(
`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<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.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<MessageRow>(
`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<IndicatorRow>(
`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<ClassificationRow>(
`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 }
);
}
}