chore: merge quick task worktree (worktree-agent-a3ab410880ddc437a)

This commit is contained in:
lorentz 2026-07-17 22:36:29 -04:00
commit 565a0c1ee2
7 changed files with 465 additions and 3 deletions

View file

@ -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 }
);
}
}

View file

@ -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 (
<Card>
<CardHeader>
@ -556,6 +600,16 @@ export function ActionAreaCard({
>
Mark as false positive
</GatedButton>
<GatedButton
disabled={!!markAccidentalReportDisabledReason}
reason={markAccidentalReportDisabledReason}
loading={isMarkingAccidentalReport}
variant="outline"
onClick={() => setAccidentalReportDialogOpen(true)}
>
Mark as accidental report
</GatedButton>
</div>
</TooltipProvider>
@ -617,6 +671,38 @@ export function ActionAreaCard({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={accidentalReportDialogOpen} onOpenChange={setAccidentalReportDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark as an accidental report?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5">
<Label htmlFor="accidental-report-reason">Reason (optional)</Label>
<Textarea
id="accidental-report-reason"
value={accidentalReportReason}
onChange={(e) => setAccidentalReportReason(e.target.value)}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isMarkingAccidentalReport}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isMarkingAccidentalReport}
onClick={(e) => {
e.preventDefault();
void handleMarkAccidentalReportConfirm();
}}
>
Mark as accidental report
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);

View file

@ -94,6 +94,13 @@ function renderEntry(entry: TimelineEntry): {
dotClass: 'bg-slate-500',
textClass: 'text-slate-600',
};
case 'campaign_marked_accidental_report':
return {
label: 'Marked as accidental report — reporter notified',
icon: CheckCircle2,
dotClass: 'bg-blue-500',
textClass: 'text-blue-600',
};
case 'campaign_classified': {
const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS' | undefined;
const tint = (verdict && VERDICT_TINT[verdict]) || 'bg-muted-foreground text-muted-foreground';

View file

@ -16,8 +16,10 @@ vi.mock('./phishing-audit', () => ({
}));
const generateAndPostAcknowledgmentMock = vi.fn();
const generateAndPostAccidentalReportNoteMock = vi.fn();
vi.mock('./triage-note-service', () => ({
generateAndPostAcknowledgment: (...args: unknown[]) => generateAndPostAcknowledgmentMock(...args),
generateAndPostAccidentalReportNote: (...args: unknown[]) => generateAndPostAccidentalReportNoteMock(...args),
}));
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
@ -25,6 +27,7 @@ import {
approveRemediationActions,
remediateApprovedActions,
markCampaignFalsePositive,
markCampaignAccidentalReport,
autoPostAcknowledgment,
RemediationValidationError,
RemediationConflictError,
@ -97,6 +100,8 @@ beforeEach(() => {
writeAuditEventMock.mockResolvedValue('audit-id');
generateAndPostAcknowledgmentMock.mockReset();
generateAndPostAcknowledgmentMock.mockResolvedValue({ noteText: 'thanks', tickets: [] });
generateAndPostAccidentalReportNoteMock.mockReset();
generateAndPostAccidentalReportNoteMock.mockResolvedValue({ noteText: 'no action needed', tickets: [] });
clientCalls = [];
});
@ -346,6 +351,79 @@ describe('markCampaignFalsePositive', () => {
});
});
// =============================================================================
// markCampaignAccidentalReport (D-04 guard, quick task 260717-v6c)
// =============================================================================
describe('markCampaignAccidentalReport', () => {
it('throws RemediationConflictError when any remediation_actions row has status approved/completed', async () => {
stage({
guardRows: [{ id: 'action-1' }],
});
await expect(
markCampaignAccidentalReport('campaign-1', 'operator@example.com')
).rejects.toThrow(RemediationConflictError);
expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(0);
expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled();
});
it('sets campaigns.status to accidental_report and writes one audit row when there are no approved/completed rows', async () => {
stage({
guardRows: [],
campaign: [{ status: 'open' }],
});
const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com', 'reported by mistake');
expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
expect(writeAuditEventMock).toHaveBeenCalledTimes(1);
const [auditArgs] = writeAuditEventMock.mock.calls[0];
expect(auditArgs).toMatchObject({
campaignId: 'campaign-1',
eventType: 'campaign_marked_accidental_report',
payload: { previousStatus: 'open', reason: 'reported by mistake' },
});
expect(result).toMatchObject({
campaignId: 'campaign-1',
status: 'accidental_report',
notePosted: true,
});
expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledTimes(1);
expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledWith('campaign-1');
});
it('throws RemediationValidationError when the campaign does not exist', async () => {
stage({
guardRows: [],
campaign: [],
});
await expect(
markCampaignAccidentalReport('campaign-missing', 'operator@example.com')
).rejects.toThrow(RemediationValidationError);
expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled();
});
it('still returns the committed status change with notePosted:false and noteError set when the note post fails', async () => {
stage({
guardRows: [],
campaign: [{ status: 'open' }],
});
generateAndPostAccidentalReportNoteMock.mockRejectedValueOnce(new Error('Autotask unavailable'));
const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com');
expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
expect(result).toMatchObject({
campaignId: 'campaign-1',
status: 'accidental_report',
notePosted: false,
noteError: 'Autotask unavailable',
});
});
});
// =============================================================================
// autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01)
// =============================================================================

View file

@ -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<MarkAccidentalReportResult> {
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<CampaignStatusRow>(
`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)
// =============================================================================

View file

@ -36,7 +36,11 @@ vi.mock('./triage-note-format', async (importOriginal) => {
});
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import { generateAndPostTriageNote, generateAndPostAcknowledgment } from './triage-note-service';
import {
generateAndPostTriageNote,
generateAndPostAcknowledgment,
generateAndPostAccidentalReportNote,
} from './triage-note-service';
interface MockRows {
reports?: unknown[];
@ -323,3 +327,69 @@ describe('generateAndPostAcknowledgment', () => {
expect(createEntityMock).not.toHaveBeenCalled();
});
});
describe('generateAndPostAccidentalReportNote', () => {
it('posts a customer-visible (noteType 18, publish 1) reviewed-report note to every linked ticket', async () => {
stage({
reports: [
report({ id: 'r1', ticket_id: '1001' }),
report({ id: 'r2', ticket_id: '1002' }),
],
});
const result = await generateAndPostAccidentalReportNote('campaign-1');
expect(createEntityMock).toHaveBeenCalledTimes(2);
for (const [entityName, data] of createEntityMock.mock.calls) {
expect(entityName).toBe('TicketNotes');
expect(data).toMatchObject({
description: result.noteText,
noteType: 18,
publish: 1,
});
expect(typeof (data as { ticketID: unknown }).ticketID).toBe('number');
}
expect(createEntityMock.mock.calls.map((c) => (c[1] as { ticketID: number }).ticketID)).toEqual([1001, 1002]);
expect(result.tickets).toEqual([
{ ticketId: '1001', posted: true },
{ ticketId: '1002', posted: true },
]);
expect(result.noteText.length).toBeGreaterThan(0);
// Fixed template, zero evidence/URL/classification interpolation (T-23-01).
expect(result.noteText).not.toContain('Blast Radius');
expect(result.noteText).not.toContain('Recommended Actions');
});
it('isolates a single ticket write failure without aborting the remaining writes', async () => {
stage({
reports: [
report({ id: 'r1', ticket_id: '1001' }),
report({ id: 'r2', ticket_id: '1002' }),
report({ id: 'r3', ticket_id: '1003' }),
],
});
createEntityMock
.mockResolvedValueOnce({ id: 1 })
.mockRejectedValueOnce(new Error('Autotask API unavailable'))
.mockResolvedValueOnce({ id: 3 });
const result = await generateAndPostAccidentalReportNote('campaign-1');
expect(createEntityMock).toHaveBeenCalledTimes(3);
expect(result.tickets[0]).toEqual({ ticketId: '1001', posted: true });
expect(result.tickets[1]).toMatchObject({ ticketId: '1002', posted: false });
expect(result.tickets[1].error).toBe('Autotask API unavailable');
expect(result.tickets[2]).toEqual({ ticketId: '1003', posted: true });
});
it('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => {
stage({ reports: [] });
const result = await generateAndPostAccidentalReportNote('campaign-empty');
expect(result.tickets).toEqual([]);
expect(typeof result.noteText).toBe('string');
expect(result.noteText.length).toBeGreaterThan(0);
expect(createEntityMock).not.toHaveBeenCalled();
});
});

View file

@ -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<TriageNoteResult> {
const reportsRes = await postgresClient.query<Pick<ReportRow, 'id' | 'ticket_id'>>(
`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 };
}