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).
This commit is contained in:
lorentz 2026-07-16 12:13:50 -04:00
parent 34a0269e9b
commit 2d410f8d15

View file

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