From 97804f2e5bcafee97cb02f98678710693a509bed Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 22:33:09 -0400 Subject: [PATCH] feat(260717-v6c): add POST /api/phishing/campaigns/[id]/mark-accidental-report route Mirrors mark-false-positive route exactly (requirePermission phishing/approve, UUID validation, optional reason body, campaign-existence check, 409/400/500 error mapping) but calls markCampaignAccidentalReport and returns the richer result including notePosted/noteError. --- .../[id]/mark-accidental-report/route.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/mark-accidental-report/route.ts diff --git a/app/api/phishing/campaigns/[id]/mark-accidental-report/route.ts b/app/api/phishing/campaigns/[id]/mark-accidental-report/route.ts new file mode 100644 index 0000000..d917f0c --- /dev/null +++ b/app/api/phishing/campaigns/[id]/mark-accidental-report/route.ts @@ -0,0 +1,83 @@ +/** + * POST /api/phishing/campaigns/[id]/mark-accidental-report + * + * Marks a campaign as an accidental report — an employee flagged a + * legitimate email by mistake. Gated by phishing/approve (same elevated + * tier as mark-false-positive; no separate action key). Validates the + * campaign id as a UUID, optionally accepts a JSON body with a `reason` + * string, and delegates to `markCampaignAccidentalReport`, which guards + * against marking a campaign that already has approved/completed + * remediation (RemediationConflictError -> 409). Unlike mark-false-positive, + * this also posts a customer-facing "reviewed, no action needed" note to + * every reporting employee's ticket — the response includes + * notePosted/noteError so the caller can distinguish full success from + * status-changed-but-note-failed. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + markCampaignAccidentalReport, + RemediationValidationError, + RemediationConflictError, +} from '@/lib/services/remediation-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 { session, error } = await requirePermission('phishing', 'approve'); + if (error) return error; + + const { id } = await params; + // 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 }); + } + + // Body is optional — tolerate an empty/absent body (default reason undefined). + let reason: string | undefined; + const rawBody = await request.text(); + if (rawBody.trim().length > 0) { + try { + const parsed = JSON.parse(rawBody) as { reason?: unknown }; + reason = typeof parsed.reason === 'string' ? parsed.reason : undefined; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + } + + const actor = (session?.user as { email?: string } | undefined)?.email ?? null; + + 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 markCampaignAccidentalReport(id, actor, reason); + return NextResponse.json(result); + } catch (err) { + if (err instanceof RemediationConflictError) { + return NextResponse.json({ error: err.message }, { status: 409 }); + } + if (err instanceof RemediationValidationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + console.error('[PHISHING-MARK-ACCIDENTAL] Failed to mark campaign as accidental report', id, err); + return NextResponse.json( + { + error: 'Failed to mark campaign as accidental report', + message: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ); + } +}