From aea4fd2f0c9029cbd083f6833c1d7876ab410c9b Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 22:32:45 -0400 Subject: [PATCH] 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 }; +}