diff --git a/app/api/phishing/campaigns/[id]/triage-note/route.ts b/app/api/phishing/campaigns/[id]/triage-note/route.ts new file mode 100644 index 0000000..50ce398 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/triage-note/route.ts @@ -0,0 +1,59 @@ +/** + * POST /api/phishing/campaigns/[id]/triage-note + * + * On-demand (re-)triggerable (D-03 — no dedupe/skip tracking) triage-note + * generation + post for a campaign. Structural twin of + * `app/api/phishing/campaigns/[id]/classify/route.ts` — enforces the same + * `phishing:analyze` permission tier (informational action, not a + * state-changing security decision like approve/remediate — CONTEXT.md + * Claude's Discretion), validates the campaign id as a UUID (V5), 404s an + * unknown campaign, then delegates to `generateAndPostTriageNote` and + * returns its result verbatim (D-06 — note text + per-ticket status list, + * never reshaped). No audit-event write here (CONTEXT.md: sent-note history + * is a deferred idea, out of scope for this phase). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { generateAndPostTriageNote } from '@/lib/services/triage-note-service'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requirePermission('phishing', 'analyze'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed id 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<{ id: string }>( + `SELECT id FROM campaigns WHERE id = $1`, + [id] + ); + if (!campaignRes.rows[0]) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + // Outer catch below only fires for whole-request failures (e.g. DB + // unreachable) — per-ticket Autotask write failures are already captured + // inside the service and returned in this 200 body (D-05). + const result = await generateAndPostTriageNote(id); + + return NextResponse.json(result); + } catch (err) { + console.error('[PHISHING-TRIAGE-NOTE] Failed to generate triage note', id, err); + return NextResponse.json( + { error: 'Failed to generate triage note', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}