chore: merge executor worktree (21-02)

This commit is contained in:
lorentz 2026-07-16 12:15:52 -04:00
commit 28f604b1cf
4 changed files with 620 additions and 0 deletions

View 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.

View 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 }
);
}
}

View 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');
});
});

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