feat(18-02): add POST /api/phishing/tickets/[ticket_id]/analyze route

- Orchestrates detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign
- requirePermission('phishing','analyze') gate first-line (D-06, 401/403)
- Validates ticket_id numeric (400), missing ticket (404), non-phishing ticket (400)
- No skipIfAlreadyGrouped (D-08) — always re-runs grouping on demand
This commit is contained in:
lorentz 2026-07-15 19:30:46 -04:00
parent c77b7edae7
commit de013e6ec1

View file

@ -0,0 +1,90 @@
/**
* POST /api/phishing/tickets/{ticket_id}/analyze
*
* On-demand trigger for one ticket: detect -> parse EML -> group into
* campaign. Unlike the automatic webhook/cron paths, this always re-runs
* groupReportIntoCampaign (no skipIfAlreadyGrouped) since parseAndStoreMessage
* may have just written new messages/indicators rows that allow a Tier-3
* grouping to upgrade to Tier-1/Tier-2 (D-08).
*/
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
import { detectPhishingTicket, type DetectableTicket } from '@/lib/services/phishing-detector';
import { parseAndStoreMessage } from '@/lib/services/phishing-eml-service';
import { groupReportIntoCampaign } from '@/lib/services/campaign-grouping-service';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ ticket_id: string }> }
) {
const { error } = await requirePermission('phishing', 'analyze');
if (error) return error;
const { ticket_id } = await params;
const ticketId = Number(ticket_id);
if (!Number.isFinite(ticketId)) {
return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 });
}
try {
const row = await postgresClient.query<{
id: string;
ticket_number: string | null;
title: string | null;
description: string | null;
company_id: number | null;
contact_id: number | null;
created_by_contact_id: number | null;
}>(
`SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id
FROM tickets WHERE id = $1`,
[ticketId]
);
const r = row.rows[0];
if (!r) {
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
}
const ticket: DetectableTicket = {
id: Number(r.id),
ticket_number: r.ticket_number,
title: r.title,
description: r.description,
company_id: r.company_id,
contact_id: r.contact_id,
created_by_contact_id: r.created_by_contact_id,
};
const detection = await detectPhishingTicket(ticket);
if (!detection.flagged || !detection.reportId) {
return NextResponse.json(
{ error: 'Ticket does not match known phishing patterns' },
{ status: 400 }
);
}
// parseAndStoreMessage never throws for expected no-op cases (returns
// { stored: false, reason }) — grouping still proceeds regardless.
await parseAndStoreMessage({ reportId: detection.reportId, ticketId });
// D-08: /analyze always re-runs grouping unconditionally (no
// skipIfAlreadyGrouped) — allows a Tier-3 grouping to upgrade to Tier-1
// now that messages/indicators rows may exist.
const grouped = await groupReportIntoCampaign(detection.reportId);
return NextResponse.json({
reportId: detection.reportId,
campaignId: grouped?.campaignId ?? null,
groupMethod: grouped?.groupMethod ?? null,
created: grouped?.created ?? false,
});
} catch (err) {
console.error('[PHISHING-ANALYZE] Failed to analyze ticket', ticketId, err);
return NextResponse.json(
{ error: 'Failed to analyze ticket', message: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 }
);
}
}