- 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)
173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
/**
|
|
* 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 }
|
|
);
|
|
}
|
|
}
|