From 3e8d5b83c9e4fdb9b63d0810454d272f61e7f63d Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 08:26:20 -0400 Subject: [PATCH] feat(19-02): add POST /api/phishing/campaigns/[id]/classify route - requirePermission('phishing','analyze') early-return (same action as /analyze, Phase 18 D-06) - UUID_RE guard on campaign id before any DB query (T-19-05) - 404 when campaign id is well-formed but not found - delegates to classifyCampaign(id) from lib/services/campaign-classifier.ts (Plan 01), returns flat ClassifyResult payload --- .../phishing/campaigns/[id]/classify/route.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/classify/route.ts diff --git a/app/api/phishing/campaigns/[id]/classify/route.ts b/app/api/phishing/campaigns/[id]/classify/route.ts new file mode 100644 index 0000000..74f5db2 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/classify/route.ts @@ -0,0 +1,51 @@ +/** + * POST /api/phishing/campaigns/[id]/classify + * + * On-demand (re-)trigger for campaign classification (D-02). Enforces the + * same phishing/analyze permission gate as + * `/api/phishing/tickets/[ticket_id]/analyze` (Phase 18 D-06 convention — + * NOT a new permission), validates the campaign id as a UUID (V5), then + * delegates to `classifyCampaign` (Plan 01) and returns the flat camelCase + * verdict payload. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { classifyCampaign } from '@/lib/services/campaign-classifier'; + +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 }); + } + + const result = await classifyCampaign(id); + return NextResponse.json(result); + } catch (err) { + console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); + return NextResponse.json( + { error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}