chore: merge executor worktree (21-02)
This commit is contained in:
commit
28f604b1cf
4 changed files with 620 additions and 0 deletions
107
.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md
Normal file
107
.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
phase: 21-autotask-triage-note
|
||||
plan: 02
|
||||
subsystem: api
|
||||
tags: [phishing-triage, autotask, triage-note, vitest, api-route]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 21-autotask-triage-note
|
||||
plan: 01
|
||||
provides: "formatTriageNote(evidence) + TriageNoteEvidence contract, sanitizeUrl/sanitizeNoteText"
|
||||
- phase: 17-mimecast-blast-radius-lookup
|
||||
provides: getBlastRadius(input) -> BlastRadiusResult
|
||||
- phase: 19-classification-engine
|
||||
provides: classifications table (verdict/confidence/summary/reasons/recommended_actions)
|
||||
- phase: 20-remediation-approval-audit-safety
|
||||
provides: remediation_actions status lifecycle rows
|
||||
provides:
|
||||
- "generateAndPostTriageNote(campaignId) orchestrator (lib/services/triage-note-service.ts)"
|
||||
- "POST /api/phishing/campaigns/[id]/triage-note endpoint"
|
||||
affects: [22-approval-ui-livelink-addressable-campaign-review-and-approve]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Per-ticket try/catch INSIDE the write loop (not around it) so one Autotask write failure never aborts remaining writes (D-05)"
|
||||
- "NUMERIC confidence column defensively coerced with both a SQL ::float8 cast and a runtime Number() wrap, so the number|null contract holds even against a string-returning mock/driver"
|
||||
- "Service always returns generated note text regardless of write outcome (D-06) — never a silent partial rollback"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- lib/services/triage-note-service.ts
|
||||
- lib/services/triage-note-service.test.ts
|
||||
- app/api/phishing/campaigns/[id]/triage-note/route.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Blast-radius sender/recipient inputs use best-available data from the bounded reports query (title only, sender/recipient left empty) rather than adding extra contact/message-header queries — getBlastRadius degrades gracefully (never throws) on sparse input, and the plan's evidence-gathering SQL is limited to the four queries it specifies (reports, classifications, remediation_actions, indicators)"
|
||||
- "Verdict is cast from the classifications.verdict TEXT column to TriageNoteEvidence['verdict'] rather than validated against the union at runtime — consistent with how campaign-classifier.ts/classify route already trust this column"
|
||||
|
||||
requirements-completed: [NOTE-01]
|
||||
|
||||
# Metrics
|
||||
duration: ~11min
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 21 Plan 02: Triage-Note Service + Endpoint Summary
|
||||
|
||||
**`generateAndPostTriageNote(campaignId)` gathers current campaign evidence (linked reports, real url indicators via the reports→messages→indicators join, most-recent classification with a numeric-coerced confidence, current remediation state, fresh blast radius), renders it via Plan 01's sanitized formatter, and posts one internal Autotask TicketNote per linked ticket with independent per-ticket failure capture — exposed via `POST /api/phishing/campaigns/{id}/triage-note`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~11 min
|
||||
- **Started:** 2026-07-16T16:03:00Z (approx.)
|
||||
- **Completed:** 2026-07-16T16:14:33Z
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 3 (all newly created)
|
||||
|
||||
## Accomplishments
|
||||
- `generateAndPostTriageNote(campaignId)` orchestrates: linked-reports read, most-recent classification read (with `confidence::float8` SQL cast + defensive `Number()` runtime coercion), current `remediation_actions` state, and a real `reports → messages → indicators` join filtered to `indicator_type = 'url'` for extracted indicator URLs
|
||||
- Fresh `getBlastRadius()` lookup per call (D-04 — always current, not frozen at classify-time)
|
||||
- Builds a `TriageNoteEvidence` object and calls Plan 01's `formatTriageNote()` to get the sanitized note text
|
||||
- Posts one `createEntity('TicketNotes', { ticketID, title, description, noteType: 1, publish: 1 })` write per linked ticket, each in its OWN try/catch (D-05) — a single ticket's failure is captured as `{ ticketId, posted: false, error }` without aborting the remaining writes
|
||||
- `noteText` is always returned regardless of write outcome (D-06) — verified by a test where the only linked ticket's write fails and `noteText` is still non-empty
|
||||
- `POST /api/phishing/campaigns/[id]/triage-note` — structural twin of `classify/route.ts`: `requirePermission('phishing', 'analyze')` gate, UUID guard (400), campaign-exists check (404), delegates to the service, returns its result verbatim, no audit-event write (deferred per CONTEXT.md)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Task 1 followed RED → GREEN (TDD):
|
||||
1. **Task 1: triage-note-service** - `34a0269` (test: RED, verified failing — module did not exist) → `2d410f8` (feat: GREEN, all 8 tests pass)
|
||||
2. **Task 2: POST route** - `e3a9cb5` (feat, no TDD gate — `type="auto"` without `tdd="true"`)
|
||||
|
||||
_TDD gate compliance: Task 1 has a `test(...)` commit followed by a `feat(...)` commit; RED was verified by temporarily removing the implementation file and confirming the suite failed with "Cannot find module" before restoring it and re-running to GREEN. No refactor step was needed._
|
||||
|
||||
## Files Created/Modified
|
||||
- `lib/services/triage-note-service.ts` - `TriageNotePostResult`/`TriageNoteResult` interfaces + `generateAndPostTriageNote(campaignId)` orchestrator
|
||||
- `lib/services/triage-note-service.test.ts` - 8 Vitest cases: all-succeed write loop, one-of-three-fails isolation (D-05), note-text-always-returned (D-06), sanitized-URL-flows-into-note, zero-url-indicators, string-confidence coercion, zero-linked-reports, no-classification-row
|
||||
- `app/api/phishing/campaigns/[id]/triage-note/route.ts` - `POST` handler: auth gate, UUID guard, 404, delegate, verbatim return, 500 catch
|
||||
|
||||
## Decisions Made
|
||||
- Blast-radius sender/recipient use best-available data (title only) from the bounded reports query rather than adding extra contact/message-header lookups — matches the plan's literal four-query evidence-gathering scope and `getBlastRadius()`'s documented graceful-degradation behavior on sparse input (never throws).
|
||||
- `classifications.verdict` (a TEXT column) is cast to `TriageNoteEvidence['verdict']` without an additional runtime validation step, consistent with how the existing classify route and campaign-classifier.ts already trust this column's values.
|
||||
- Route uses `phishing:analyze` (not `phishing:approve`) per CONTEXT.md's explicit lean — note generation is informational, not a state-changing security decision.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Both tasks' `<behavior>`, `<action>`, and `<acceptance_criteria>` blocks were implemented as specified; no architectural changes, no missing critical functionality found, no blocking issues encountered.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required beyond what Phases 17-20 already established (Autotask/Mimecast env vars, if configured).
|
||||
|
||||
## Next Phase Readiness
|
||||
- `generateAndPostTriageNote()` and the new `POST /api/phishing/campaigns/{id}/triage-note` endpoint are ready for Phase 22's LiveLink approval UI to call as its "send triage note" action.
|
||||
- No blockers.
|
||||
|
||||
---
|
||||
*Phase: 21-autotask-triage-note*
|
||||
*Completed: 2026-07-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files (`lib/services/triage-note-service.ts`, `lib/services/triage-note-service.test.ts`, `app/api/phishing/campaigns/[id]/triage-note/route.ts`) and task commit hashes (`34a0269`, `2d410f8`, `e3a9cb5`) verified present on disk / in git history.
|
||||
59
app/api/phishing/campaigns/[id]/triage-note/route.ts
Normal file
59
app/api/phishing/campaigns/[id]/triage-note/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* POST /api/phishing/campaigns/[id]/triage-note
|
||||
*
|
||||
* On-demand (re-)triggerable (D-03 — no dedupe/skip tracking) triage-note
|
||||
* generation + post for a campaign. Structural twin of
|
||||
* `app/api/phishing/campaigns/[id]/classify/route.ts` — enforces the same
|
||||
* `phishing:analyze` permission tier (informational action, not a
|
||||
* state-changing security decision like approve/remediate — CONTEXT.md
|
||||
* Claude's Discretion), validates the campaign id as a UUID (V5), 404s an
|
||||
* unknown campaign, then delegates to `generateAndPostTriageNote` and
|
||||
* returns its result verbatim (D-06 — note text + per-ticket status list,
|
||||
* never reshaped). No audit-event write here (CONTEXT.md: sent-note history
|
||||
* is a deferred idea, out of scope for this phase).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { generateAndPostTriageNote } from '@/lib/services/triage-note-service';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requirePermission('phishing', 'analyze');
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
// V5: validate UUID shape before querying — a malformed id would otherwise
|
||||
// surface as an unhandled Postgres error -> uncaught 500.
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const campaignRes = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id FROM campaigns WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!campaignRes.rows[0]) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Outer catch below only fires for whole-request failures (e.g. DB
|
||||
// unreachable) — per-ticket Autotask write failures are already captured
|
||||
// inside the service and returned in this 200 body (D-05).
|
||||
const result = await generateAndPostTriageNote(id);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
console.error('[PHISHING-TRIAGE-NOTE] Failed to generate triage note', id, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate triage note', message: err instanceof Error ? err.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
259
lib/services/triage-note-service.test.ts
Normal file
259
lib/services/triage-note-service.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock postgresClient (default export), autotask-factory, and mimecast-blast-radius
|
||||
// BEFORE importing the module under test — mirrors remediation-service.test.ts's
|
||||
// vi.mock + vi.fn() dispatch-by-SQL-substring pattern.
|
||||
const queryMock = vi.fn();
|
||||
vi.mock('./postgres-client', () => ({
|
||||
__esModule: true,
|
||||
default: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
const createEntityMock = vi.fn();
|
||||
vi.mock('./autotask-factory', () => ({
|
||||
getAutotaskClient: () => ({ createEntity: (...args: unknown[]) => createEntityMock(...args) }),
|
||||
}));
|
||||
|
||||
const getBlastRadiusMock = vi.fn();
|
||||
vi.mock('./mimecast-blast-radius', () => ({
|
||||
getBlastRadius: (...args: unknown[]) => getBlastRadiusMock(...args),
|
||||
}));
|
||||
|
||||
// Wrap the real formatTriageNote so tests can inspect the exact evidence
|
||||
// object it was called with (e.g. asserting `confidence` is a JS number),
|
||||
// while still exercising the real sanitize/format logic for noteText
|
||||
// assertions.
|
||||
const formatTriageNoteSpy = vi.fn();
|
||||
vi.mock('./triage-note-format', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./triage-note-format')>();
|
||||
return {
|
||||
...actual,
|
||||
formatTriageNote: (evidence: unknown) => {
|
||||
formatTriageNoteSpy(evidence);
|
||||
return actual.formatTriageNote(evidence as Parameters<typeof actual.formatTriageNote>[0]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
||||
import { generateAndPostTriageNote } from './triage-note-service';
|
||||
|
||||
interface MockRows {
|
||||
reports?: unknown[];
|
||||
classification?: unknown[];
|
||||
remediation?: unknown[];
|
||||
urlIndicators?: unknown[];
|
||||
}
|
||||
|
||||
function stage(rows: MockRows) {
|
||||
queryMock.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('FROM reports WHERE campaign_id')) {
|
||||
return { rows: rows.reports ?? [] };
|
||||
}
|
||||
if (sql.includes('FROM classifications')) {
|
||||
return { rows: rows.classification ?? [] };
|
||||
}
|
||||
if (sql.includes('FROM remediation_actions')) {
|
||||
return { rows: rows.remediation ?? [] };
|
||||
}
|
||||
if (sql.includes('FROM indicators') && sql.includes("indicator_type = 'url'")) {
|
||||
return { rows: rows.urlIndicators ?? [] };
|
||||
}
|
||||
throw new Error(`Unstaged query in test mock: ${sql}`);
|
||||
});
|
||||
}
|
||||
|
||||
const FIXED_BLAST_RADIUS = {
|
||||
status: 'ok' as const,
|
||||
matched: 3,
|
||||
delivered: 2,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out' as const,
|
||||
};
|
||||
|
||||
function report(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'report-1',
|
||||
ticket_id: '1001',
|
||||
ticket_number: 'T-1001',
|
||||
title: 'Suspicious email',
|
||||
company_name: 'Acme Corp',
|
||||
requester_contact_id: 55,
|
||||
evidence: {},
|
||||
created_at: '2026-07-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
createEntityMock.mockReset();
|
||||
createEntityMock.mockResolvedValue({ id: 999 });
|
||||
getBlastRadiusMock.mockReset();
|
||||
getBlastRadiusMock.mockResolvedValue(FIXED_BLAST_RADIUS);
|
||||
formatTriageNoteSpy.mockReset();
|
||||
});
|
||||
|
||||
describe('generateAndPostTriageNote', () => {
|
||||
it('posts one TicketNote per linked report, all posted:true when every write succeeds', async () => {
|
||||
stage({
|
||||
reports: [
|
||||
report({ id: 'r1', ticket_id: '1001' }),
|
||||
report({ id: 'r2', ticket_id: '1002' }),
|
||||
report({ id: 'r3', ticket_id: '1003' }),
|
||||
],
|
||||
classification: [
|
||||
{
|
||||
verdict: 'THREAT',
|
||||
confidence: 0.8,
|
||||
summary: 'summary',
|
||||
reasons: ['reason one'],
|
||||
recommended_actions: ['block_sender'],
|
||||
requires_approval: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
expect(createEntityMock).toHaveBeenCalledTimes(3);
|
||||
for (const [entityName, data] of createEntityMock.mock.calls) {
|
||||
expect(entityName).toBe('TicketNotes');
|
||||
expect(data).toMatchObject({
|
||||
description: result.noteText,
|
||||
noteType: 1,
|
||||
publish: 1,
|
||||
});
|
||||
expect(typeof (data as { ticketID: unknown }).ticketID).toBe('number');
|
||||
}
|
||||
expect(createEntityMock.mock.calls.map((c) => (c[1] as { ticketID: number }).ticketID)).toEqual([
|
||||
1001, 1002, 1003,
|
||||
]);
|
||||
|
||||
expect(result.tickets).toEqual([
|
||||
{ ticketId: '1001', posted: true },
|
||||
{ ticketId: '1002', posted: true },
|
||||
{ ticketId: '1003', posted: true },
|
||||
]);
|
||||
expect(result.noteText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('captures a single ticket write failure without aborting the remaining writes (D-05)', async () => {
|
||||
stage({
|
||||
reports: [
|
||||
report({ id: 'r1', ticket_id: '1001' }),
|
||||
report({ id: 'r2', ticket_id: '1002' }),
|
||||
report({ id: 'r3', ticket_id: '1003' }),
|
||||
],
|
||||
classification: [
|
||||
{
|
||||
verdict: 'SPAM',
|
||||
confidence: 0.5,
|
||||
summary: 'summary',
|
||||
reasons: [],
|
||||
recommended_actions: ['no_action'],
|
||||
requires_approval: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
createEntityMock
|
||||
.mockResolvedValueOnce({ id: 1 })
|
||||
.mockRejectedValueOnce(new Error('Autotask API unavailable'))
|
||||
.mockResolvedValueOnce({ id: 3 });
|
||||
|
||||
const result = await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
expect(createEntityMock).toHaveBeenCalledTimes(3);
|
||||
expect(result.tickets[0]).toEqual({ ticketId: '1001', posted: true });
|
||||
expect(result.tickets[1]).toMatchObject({ ticketId: '1002', posted: false });
|
||||
expect(result.tickets[1].error).toBe('Autotask API unavailable');
|
||||
expect(result.tickets[2]).toEqual({ ticketId: '1003', posted: true });
|
||||
});
|
||||
|
||||
it('always returns non-empty noteText even when a write fails (D-06)', async () => {
|
||||
stage({
|
||||
reports: [report({ id: 'r1', ticket_id: '1001' })],
|
||||
classification: [],
|
||||
});
|
||||
createEntityMock.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const result = await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
expect(typeof result.noteText).toBe('string');
|
||||
expect(result.noteText.length).toBeGreaterThan(0);
|
||||
expect(result.tickets).toEqual([{ ticketId: '1001', posted: false, error: 'boom' }]);
|
||||
});
|
||||
|
||||
it('flows a sanitized indicator URL (token query param stripped) into the posted note text', async () => {
|
||||
stage({
|
||||
reports: [report({ id: 'r1', ticket_id: '1001' })],
|
||||
classification: [],
|
||||
urlIndicators: [{ value: 'http://evil.example/p?token=leak' }],
|
||||
});
|
||||
|
||||
const result = await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
expect(result.noteText).toContain('http://evil.example/p');
|
||||
expect(result.noteText).not.toContain('token=leak');
|
||||
});
|
||||
|
||||
it('resolves with evidence.urls === [] and still succeeds when there are no url indicators', async () => {
|
||||
stage({
|
||||
reports: [report({ id: 'r1', ticket_id: '1001' })],
|
||||
classification: [],
|
||||
urlIndicators: [],
|
||||
});
|
||||
|
||||
await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
const evidenceArg = formatTriageNoteSpy.mock.calls[0][0] as { urls: string[] };
|
||||
expect(evidenceArg.urls).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces a string-typed NUMERIC confidence (e.g. "0.92" from the mocked query) to a JS number', async () => {
|
||||
stage({
|
||||
reports: [report({ id: 'r1', ticket_id: '1001' })],
|
||||
classification: [
|
||||
{
|
||||
verdict: 'THREAT',
|
||||
confidence: '0.92', // simulates node-pg's real bare-NUMERIC-column behavior
|
||||
summary: 'summary',
|
||||
reasons: [],
|
||||
recommended_actions: ['block_sender'],
|
||||
requires_approval: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
const evidenceArg = formatTriageNoteSpy.mock.calls[0][0] as { confidence: unknown };
|
||||
expect(typeof evidenceArg.confidence).toBe('number');
|
||||
expect(evidenceArg.confidence).toBe(0.92);
|
||||
});
|
||||
|
||||
it('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => {
|
||||
stage({ reports: [], classification: [] });
|
||||
|
||||
const result = await generateAndPostTriageNote('campaign-empty');
|
||||
|
||||
expect(result.tickets).toEqual([]);
|
||||
expect(typeof result.noteText).toBe('string');
|
||||
expect(result.noteText.length).toBeGreaterThan(0);
|
||||
expect(createEntityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves (renders "not yet classified") for a campaign with no classification row', async () => {
|
||||
stage({
|
||||
reports: [report({ id: 'r1', ticket_id: '1001' })],
|
||||
classification: [],
|
||||
});
|
||||
|
||||
const result = await generateAndPostTriageNote('campaign-1');
|
||||
|
||||
expect(result.noteText).toContain('not yet classified');
|
||||
});
|
||||
});
|
||||
195
lib/services/triage-note-service.ts
Normal file
195
lib/services/triage-note-service.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue