From 2d410f8d15495b4a80030ff21b945ef0aeb12e5c Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:13:50 -0400 Subject: [PATCH] feat(21-02): implement triage-note-service (evidence gather + note post loop) GREEN: generateAndPostTriageNote(campaignId) gathers linked reports, most-recent classification (NUMERIC confidence coerced to a JS number), current remediation_actions, and real url indicators via the reports->messages->indicators join; renders the sanitized note via Plan 01's formatTriageNote, then posts one internal TicketNotes write per linked ticket with independent per-ticket error capture so a single write failure never aborts the call (D-05) and note text is always returned (D-06). --- lib/services/triage-note-service.ts | 195 ++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 lib/services/triage-note-service.ts diff --git a/lib/services/triage-note-service.ts b/lib/services/triage-note-service.ts new file mode 100644 index 0000000..06bb155 --- /dev/null +++ b/lib/services/triage-note-service.ts @@ -0,0 +1,195 @@ +/** + * 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/catch INSIDE the + * 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 { + const reportsRes = await postgresClient.query( + `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( + `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( + `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( + `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 }; +}