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:
lorentz 2026-07-17 22:32:45 -04:00
parent 2b48dc0c10
commit aea4fd2f0c
2 changed files with 139 additions and 1 deletions

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

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