chore: merge quick task worktree (worktree-agent-a3ab410880ddc437a)
This commit is contained in:
commit
565a0c1ee2
7 changed files with 465 additions and 3 deletions
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue