2026-07-16 12:13:50 -04:00
/ * *
* Triage - note service ( Phase 21 , NOTE - 01 ) .
*
* Gathers a campaign ' s CURRENT evidence — linked reports / tickets , extracted
* url indicators ( Phase 16 ) , most - recent classification ( Phase 19 ) , current
* remediation state ( Phase 20 ) , and a fresh blast - radius lookup ( Phase 17 ) —
* renders it through Plan 01 ' s ` formatTriageNote() ` ( already sanitized ) , and
* posts one internal ( non - portal ) Autotask ` TicketNotes ` entry per linked
* ticket via the existing safe write path ( ` workflow-engine.ts ` ' s
* ` createEntity('TicketNotes', ...) ` precedent ) .
*
* D - 05 / D - 06 : each ticket ' s write is attempted in its own try / c a t c h I N S I D E t h e
* loop — one ticket ' s Autotask failure never aborts the remaining writes , and
* the generated note text is always returned regardless of write outcome .
* /
import postgresClient from './postgres-client' ;
import { getAutotaskClient } from './autotask-factory' ;
import { getBlastRadius , type BlastRadiusResult } from './mimecast-blast-radius' ;
import { formatTriageNote , type TriageNoteEvidence } from './triage-note-format' ;
export interface TriageNotePostResult {
ticketId : string ;
posted : boolean ;
error? : string ;
}
export interface TriageNoteResult {
noteText : string ;
tickets : TriageNotePostResult [ ] ;
}
interface ReportRow {
id : string ;
ticket_id : string ;
ticket_number : string | null ;
title : string | null ;
company_name : string | null ;
requester_contact_id : number | null ;
evidence : unknown ;
created_at : string ;
}
interface ClassificationRow {
verdict : string | null ;
confidence : number | string | null ;
summary : string | null ;
reasons : string [ ] | string | null ;
recommended_actions : string [ ] | string | null ;
requires_approval : boolean | null ;
created_at : string ;
}
interface RemediationRow {
action_type : string ;
status : string ;
approved_by : string | null ;
approved_at : string | null ;
}
interface IndicatorUrlRow {
value : string ;
}
/** Normalizes a JSONB array column into a string[] regardless of driver JSON parsing (mirrors remediation-service.ts's parseRecommendedActions idiom). */
function parseJsonArray ( value : string [ ] | string | null | undefined ) : string [ ] {
if ( Array . isArray ( value ) ) return value ;
if ( typeof value === 'string' ) {
try {
const parsed = JSON . parse ( value ) ;
return Array . isArray ( parsed ) ? parsed : [ ] ;
} catch {
return [ ] ;
}
}
return [ ] ;
}
/ * *
* Gathers current campaign evidence , renders the sanitized triage note , and
* posts it as an internal TicketNote to every ticket linked to the campaign
* ( D - 01 ) . Always returns the note text ( D - 06 ) — a per - ticket write failure is
* captured on that ticket ' s result entry without aborting the loop ( D - 05 ) .
* /
export async function generateAndPostTriageNote ( campaignId : string ) : Promise < TriageNoteResult > {
const reportsRes = await postgresClient . query < ReportRow > (
` SELECT id::text, ticket_id::text AS ticket_id, ticket_number, title, company_name,
requester_contact_id , evidence , created_at : : text AS created_at
FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC ` ,
[ campaignId ]
) ;
const reports = reportsRes . rows ;
const classificationRes = await postgresClient . query < ClassificationRow > (
` SELECT verdict, confidence::float8 AS confidence, summary, reasons, recommended_actions,
requires_approval , created_at : : text AS created_at
FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1 ` ,
[ campaignId ]
) ;
// NUMERIC confidence comes back from node-pg as a JS string when read as a
// bare column; the `::float8` cast above makes real Postgres return a real
// number, but we still defensively coerce here so the
// `TriageNoteEvidence.confidence: number | null` contract holds even if a
// caller/mock hands back a string (e.g. an untyped test double, or a future
// driver change that stops honoring the cast).
const classification = classificationRes . rows [ 0 ] ? ? null ;
const remediationRes = await postgresClient . query < RemediationRow > (
` SELECT action_type, status, approved_by, approved_at::text AS approved_at
FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC ` ,
[ campaignId ]
) ;
// Real indicator-URL join (Phase 16 evidence) — reports.evidence has NO url
// field, so urls must come from here, not from the reports.evidence JSONB.
const urlIndicatorsRes = await postgresClient . query < IndicatorUrlRow > (
` SELECT i.value FROM indicators i
JOIN messages m ON m . id = i . message_id
JOIN reports r ON r . id = m . report_id
WHERE r . campaign_id = $1 AND i . indicator_type = 'url' ` ,
[ campaignId ]
) ;
const urls = urlIndicatorsRes . rows . map ( ( row ) = > row . value ) ;
const primaryReport = reports [ 0 ] ? ? null ;
let blastRadius : BlastRadiusResult ;
if ( primaryReport ) {
const createdAt = new Date ( primaryReport . created_at ) ;
// Best-available sender/recipient given only what the bounded reports
// query above returns (title only) — getBlastRadius never throws on
// sparse input, it degrades to `status: 'unavailable'`/empty counts.
blastRadius = await getBlastRadius ( {
sender : '' ,
recipient : '' ,
subject : primaryReport.title ? ? '' ,
dateWindow : {
start : new Date ( createdAt . getTime ( ) - 24 * 60 * 60 * 1000 ) ,
end : new Date ( createdAt . getTime ( ) + 24 * 60 * 60 * 1000 ) ,
} ,
} ) ;
} else {
blastRadius = { status : 'unavailable' , reason : 'not_configured' } ;
}
const confidence = classification ? . confidence == null ? null : Number ( classification . confidence ) ;
const evidence : TriageNoteEvidence = {
campaignId ,
reportCount : reports.length ,
companyName : primaryReport?.company_name ? ? null ,
subject : primaryReport?.title ? ? null ,
verdict : ( classification ? . verdict as TriageNoteEvidence [ 'verdict' ] ) ? ? null ,
confidence ,
summary : classification?.summary ? ? null ,
reasons : parseJsonArray ( classification ? . reasons ) ,
recommendedActions : parseJsonArray ( classification ? . recommended_actions ) ,
requiresApproval : classification?.requires_approval ? ? false ,
blastRadius ,
remediationActions : remediationRes.rows.map ( ( row ) = > ( {
actionType : row.action_type ,
status : row.status ,
approvedBy : row.approved_by ,
approvedAt : row.approved_at ,
} ) ) ,
urls ,
} ;
const noteText = formatTriageNote ( evidence ) ;
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 : 'Phishing Triage Summary' ,
description : noteText ,
noteType : 1 , // Internal
publish : 1 ,
} ) ;
tickets . push ( { ticketId : report.ticket_id , posted : true } ) ;
} catch ( err ) {
console . error ( '[PHISHING-TRIAGE-NOTE] 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 } ;
}
2026-07-16 19:37:56 -04:00
/ * *
* Phase 23 D - 02 / D - 03 : posts a short , genuinely appreciative thank - you note
* to every ticket linked to a USER_AWARENESS campaign — the ` acknowledge_user `
* delivery action . Unlike ` generateAndPostTriageNote ` ( an internal evidence
* dump ) , this note is customer - visible : ` noteType: 18 ` ( " Client Portal
* Note " , verified live against this tenant ' s TicketNotes field metadata )
* with ` publish: 1 ` left unchanged ( an internal - staff - tier field , orthogonal
* to client visibility — see triage - note - format . ts / 23 - PATTERNS . md watch - out
* flag # 1 ) . The body is a FIXED template with zero evidence / URL / classification
* interpolation ( T - 23 - 01 ) — nothing from the parsed email or classification
* reasons is ever placed in this note .
*
* Mirrors ` generateAndPostTriageNote ` ' s per - ticket try / c a t c h - i n - l o o p e r r o r
* isolation ( D - 05 ) and ` { noteText, tickets } ` return shape .
* /
export async function generateAndPostAcknowledgment ( 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 = [
2026-07-17 10:51:12 -04:00
'Thanks for reporting this one!' ,
2026-07-16 19:37:56 -04:00
'' ,
2026-07-17 10:51:12 -04:00
"Heads up — this was a simulated phishing test from our security awareness program, not a real threat. But your instinct to flag it was spot on, and that's exactly what keeps us protected when the real thing shows up." ,
'' ,
'Nicely done.' ,
2026-07-16 19:37:56 -04:00
] . join ( '\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 — Suspicious Email Reported' ,
description : noteText ,
noteType : 18 , // Client Portal Note — customer-visible (D-03)
publish : 1 ,
} ) ;
tickets . push ( { ticketId : report.ticket_id , posted : true } ) ;
} catch ( err ) {
console . error ( '[PHISHING-ACKNOWLEDGMENT] 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 } ;
}
2026-07-17 22:32:45 -04:00
/ * *
* 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 / c a t c h I N S I D E t h e l o o p ( D - 0 5 i s o l a t i o n ) ,
* 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 } ;
}