From aea4fd2f0c9029cbd083f6833c1d7876ab410c9b Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 22:32:45 -0400 Subject: [PATCH 1/4] feat(260717-v6c): add markCampaignAccidentalReport + generateAndPostAccidentalReportNote - generateAndPostAccidentalReportNote mirrors generateAndPostAcknowledgment: fixed customer-visible template (noteType 18/publish 1), per-ticket try/catch isolation, zero evidence interpolation (T-23-01) - markCampaignAccidentalReport mirrors markCampaignFalsePositive's D-04 guard/transaction shape, then posts the note post-commit outside the FOR UPDATE lock; note-post failure never propagates --- lib/services/remediation-service.ts | 87 ++++++++++++++++++++++++++++- lib/services/triage-note-service.ts | 53 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/lib/services/remediation-service.ts b/lib/services/remediation-service.ts index bc44a2d..e06e1b2 100644 --- a/lib/services/remediation-service.ts +++ b/lib/services/remediation-service.ts @@ -28,7 +28,7 @@ import { postgresClient } from './postgres-client'; import { writeAuditEvent } from './phishing-audit'; -import { generateAndPostAcknowledgment } from './triage-note-service'; +import { generateAndPostAcknowledgment, generateAndPostAccidentalReportNote } from './triage-note-service'; export class RemediationValidationError extends Error { constructor(message: string) { @@ -321,6 +321,91 @@ export async function markCampaignFalsePositive( }); } +// ============================================================================= +// markCampaignAccidentalReport (D-04 guard, quick task 260717-v6c) +// ============================================================================= + +export interface MarkAccidentalReportResult { + campaignId: string; + status: 'accidental_report'; + auditEventId: string; + notePosted: boolean; + noteError?: string; +} + +/** + * D-04 guard: rejects with RemediationConflictError when any + * approved/completed remediation exists for the campaign — mirrors + * markCampaignFalsePositive exactly. Otherwise sets + * campaigns.status='accidental_report' and writes one atomic audit row + * recording the previous status and the optional reason. AFTER the + * transaction commits, posts a fixed-template customer-visible note to every + * reporting employee's ticket (generateAndPostAccidentalReportNote) — this + * external Autotask call runs outside the FOR UPDATE-locked transaction and + * its failure is caught/logged, never propagated: the committed status + * change is returned regardless, with notePosted/noteError reflecting the + * note outcome. + */ +export async function markCampaignAccidentalReport( + campaignId: string, + actor: string | null, + reason?: string +): Promise { + const result = await postgresClient.transaction(async (client) => { + const guardRes = await client.query<{ id: string }>( + `SELECT id FROM remediation_actions + WHERE campaign_id = $1 AND status IN ('approved', 'completed') + FOR UPDATE + LIMIT 1`, + [campaignId] + ); + if (guardRes.rows.length > 0) { + throw new RemediationConflictError( + 'Cannot mark accidental report: campaign already has approved or completed remediation' + ); + } + + const campaignRes = await client.query( + `SELECT status FROM campaigns WHERE id = $1`, + [campaignId] + ); + const campaign = campaignRes.rows[0]; + if (!campaign) { + throw new RemediationValidationError('Campaign not found'); + } + const previousStatus = campaign.status; + + await client.query( + `UPDATE campaigns SET status = 'accidental_report', updated_at = NOW() WHERE id = $1`, + [campaignId] + ); + + const auditEventId = await writeAuditEvent( + { + campaignId, + actor, + eventType: 'campaign_marked_accidental_report', + payload: { previousStatus, reason: reason ?? null }, + }, + client + ); + + return { campaignId, status: 'accidental_report' as const, auditEventId }; + }); + + let notePosted = false; + let noteError: string | undefined; + try { + await generateAndPostAccidentalReportNote(campaignId); + notePosted = true; + } catch (err) { + console.error('[MARK-ACCIDENTAL-REPORT] accidental-report note post failed', campaignId, err); + noteError = err instanceof Error ? err.message : 'Unknown error'; + } + + return { ...result, notePosted, noteError }; +} + // ============================================================================= // autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01) // ============================================================================= diff --git a/lib/services/triage-note-service.ts b/lib/services/triage-note-service.ts index 4e34b20..fbced10 100644 --- a/lib/services/triage-note-service.ts +++ b/lib/services/triage-note-service.ts @@ -251,3 +251,56 @@ export async function generateAndPostAcknowledgment(campaignId: string): Promise return { noteText, tickets }; } + +/** + * Quick task 260717-v6c: posts a short, fixed-template customer-visible note + * to every ticket linked to a campaign that a reviewer has marked as an + * accidental report — an employee flagged a legitimate email by mistake. + * Mirrors `generateAndPostAcknowledgment`'s structure exactly: same reports + * lookup query, same per-ticket try/catch INSIDE the loop (D-05 isolation), + * same noteType 18 ("Client Portal Note") / publish 1, same + * `{ noteText, tickets }` return shape. The body is a FIXED template with + * zero evidence/URL/classification interpolation (T-23-01 invariant) — + * nothing from the parsed email or classification reasons is ever placed in + * this note. + */ +export async function generateAndPostAccidentalReportNote(campaignId: string): Promise { + const reportsRes = await postgresClient.query>( + `SELECT id::text, ticket_id::text AS ticket_id + FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`, + [campaignId] + ); + const reports = reportsRes.rows; + + const noteText = [ + 'Thanks for flagging this — after review, this turned out to be a legitimate email that was reported by mistake, not a phishing attempt.', + '', + "No action is needed on your part, and this report has been closed out. If anything ever looks off in the future, please keep reporting it — that's exactly the right move.", + ].join('\n\n'); + + const client = getAutotaskClient(); + const tickets: TriageNotePostResult[] = []; + for (const report of reports) { + // Per-ticket try/catch is INSIDE the loop (not around it) so one + // ticket's write failure never aborts the remaining writes (D-05). + try { + await client.createEntity('TicketNotes', { + ticketID: Number(report.ticket_id), + title: 'Thank You — Report Reviewed', + description: noteText, + noteType: 18, // Client Portal Note — customer-visible + publish: 1, + }); + tickets.push({ ticketId: report.ticket_id, posted: true }); + } catch (err) { + console.error('[PHISHING-ACCIDENTAL-REPORT] Failed to post note to ticket', report.ticket_id, err); + tickets.push({ + ticketId: report.ticket_id, + posted: false, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + return { noteText, tickets }; +} From 97804f2e5bcafee97cb02f98678710693a509bed Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 22:33:09 -0400 Subject: [PATCH 2/4] 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 } + ); + } +} From 74e43e23c4b4901c5c64f07a8ec3cba9acc8b506 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 22:34:09 -0400 Subject: [PATCH 3/4] feat(260717-v6c): add Mark as accidental report button, dialog, and timeline case - ActionAreaCard: new GatedButton + confirm AlertDialog (optional reason), resolved/tooltip logic now covers accidental_report status, toast distinguishes full success from note-post failure - TimelineCard: campaign_marked_accidental_report entry uses the blue/CheckCircle2 tint (distinct from slate/XCircle false-positive) --- components/phishing/action-area-card.tsx | 88 +++++++++++++++++++++++- components/phishing/timeline-card.tsx | 7 ++ 2 files changed, 94 insertions(+), 1 deletion(-) 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. + + +
+ +