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
This commit is contained in:
parent
2b48dc0c10
commit
aea4fd2f0c
2 changed files with 139 additions and 1 deletions
|
|
@ -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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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