diff --git a/app/api/phishing/tickets/[ticket_id]/analyze/route.ts b/app/api/phishing/tickets/[ticket_id]/analyze/route.ts new file mode 100644 index 0000000..585cc4d --- /dev/null +++ b/app/api/phishing/tickets/[ticket_id]/analyze/route.ts @@ -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 } + ); + } +}