wulf-pulse/lib/services/triage-note-service.ts
lorentz 50e241592c feat(23-01): add generateAndPostAcknowledgment customer-visible note writer
- New generateAndPostAcknowledgment(campaignId) posts a short, appreciative
  thank-you note to every ticket linked to a campaign, using noteType 18
  (Client Portal Note, verified live against tenant's TicketNotes field
  metadata) so the note is customer-visible; publish stays 1 unchanged
- Body is a fixed template with zero evidence/URL/classification
  interpolation (T-23-01) -- not the evidence-dump formatTriageNote() template
- Mirrors generateAndPostTriageNote's per-ticket try/catch-in-loop error
  isolation and { noteText, tickets } return shape
- Tests: noteType 18 + publish 1 payload assertion, per-ticket failure
  isolation, and zero-linked-reports case
2026-07-16 19:37:56 -04:00

251 lines
9.6 KiB
TypeScript

/**
* 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 };
}
/**
* 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/catch-in-loop error
* 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 = [
'Thank you for reporting this email as suspicious!',
'',
'Your quick action in flagging this message is exactly the kind of vigilance that helps keep our organization secure. We really appreciate you taking the time to report it — please keep it up.',
].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 };
}