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:
parent
c852cfee13
commit
959907d63b
1 changed files with 173 additions and 0 deletions
173
app/api/phishing/campaigns/[id]/route.ts
Normal file
173
app/api/phishing/campaigns/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue