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 } + ); + } +} diff --git a/components/phishing/action-area-card.tsx b/components/phishing/action-area-card.tsx index 99f38bc..4038db8 100644 --- a/components/phishing/action-area-card.tsx +++ b/components/phishing/action-area-card.tsx @@ -313,9 +313,12 @@ export function ActionAreaCard({ const [isApproving, setIsApproving] = useState(false); const [isRemediating, setIsRemediating] = useState(false); const [isMarkingFalsePositive, setIsMarkingFalsePositive] = useState(false); + const [isMarkingAccidentalReport, setIsMarkingAccidentalReport] = useState(false); const [remediateDialogOpen, setRemediateDialogOpen] = useState(false); const [falsePositiveDialogOpen, setFalsePositiveDialogOpen] = useState(false); const [falsePositiveReason, setFalsePositiveReason] = useState(''); + const [accidentalReportDialogOpen, setAccidentalReportDialogOpen] = useState(false); + const [accidentalReportReason, setAccidentalReportReason] = useState(''); const recommendedActionsKey = classification?.recommendedActions.join(',') ?? ''; @@ -409,7 +412,8 @@ export function ActionAreaCard({ // remediation. Once resolved, all three buttons stay in the DOM but are // disabled with a resolved-state tooltip. const completedAction = remediationActions.find((a) => a.status === 'completed'); - const resolved = campaignStatus === 'false_positive' || completedAction != null; + const resolved = + campaignStatus === 'false_positive' || campaignStatus === 'accidental_report' || completedAction != null; function resolvedTooltipCopy(): string { if (completedAction) { @@ -418,6 +422,9 @@ export function ActionAreaCard({ : 'an earlier date'; return `Already remediated on ${dateStr} by ${completedAction.approvedBy ?? 'unknown'}`; } + if (campaignStatus === 'accidental_report') { + return `Marked as an accidental report on ${new Date(campaignUpdatedAt).toLocaleDateString()}`; + } return `Marked as false positive on ${new Date(campaignUpdatedAt).toLocaleDateString()}`; } @@ -455,6 +462,14 @@ export function ActionAreaCard({ ? 'Cannot mark false positive — this campaign already has approved or completed remediation' : null; + const markAccidentalReportDisabledReason = !canApprove + ? 'Requires approve permission' + : resolved + ? resolvedTooltipCopy() + : hasBlockingRemediation + ? 'Cannot mark as accidental report — this campaign already has approved or completed remediation' + : null; + async function handleRemediateConfirm() { setIsRemediating(true); try { @@ -493,6 +508,35 @@ export function ActionAreaCard({ } } + async function handleMarkAccidentalReportConfirm() { + setIsMarkingAccidentalReport(true); + try { + const res = await fetch(`/api/phishing/campaigns/${campaignId}/mark-accidental-report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(accidentalReportReason ? { reason: accidentalReportReason } : {}), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Mark as accidental report failed'); + if (data.notePosted === false) { + toast.warning( + `Campaign marked as accidental report, but the reporter note failed to post${ + data.noteError ? `: ${data.noteError}` : '' + } — follow up manually.` + ); + } else { + toast.success('Marked as accidental report, reporter notified'); + } + setAccidentalReportDialogOpen(false); + setAccidentalReportReason(''); + onActionComplete(); + } catch (err) { + toast.error(`Mark as accidental report failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setIsMarkingAccidentalReport(false); + } + } + return ( @@ -556,6 +600,16 @@ export function ActionAreaCard({ > Mark as false positive + + setAccidentalReportDialogOpen(true)} + > + Mark as accidental report + @@ -617,6 +671,38 @@ export function ActionAreaCard({ + + + + + Mark as an accidental report? + + Mark this campaign as an accidental report? This posts a note to the reporting employee + explaining no action is needed, and closes out the campaign. This cannot be undone. + + +
+ +