From 34a0269e9b1e3abdf8138d4ff2f342a5011a5ff4 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:13:18 -0400 Subject: [PATCH 1/5] test(21-02): add failing test for triage-note service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: generateAndPostTriageNote does not exist yet — covers per-ticket write loop, D-05 partial-failure isolation, D-06 note-text-always-returned, indicator-URL sanitization flow, and NUMERIC confidence coercion. --- lib/services/triage-note-service.test.ts | 259 +++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 lib/services/triage-note-service.test.ts diff --git a/lib/services/triage-note-service.test.ts b/lib/services/triage-note-service.test.ts new file mode 100644 index 0000000..4d7e5da --- /dev/null +++ b/lib/services/triage-note-service.test.ts @@ -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(); + return { + ...actual, + formatTriageNote: (evidence: unknown) => { + formatTriageNoteSpy(evidence); + return actual.formatTriageNote(evidence as Parameters[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> = {}) { + 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'); + }); +}); From 2d410f8d15495b4a80030ff21b945ef0aeb12e5c Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:13:50 -0400 Subject: [PATCH 2/5] 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 }; +} From e3a9cb5191ad45292a6f5cf882fefcdc3e073b81 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:14:23 -0400 Subject: [PATCH 3/5] feat(21-02): add POST /api/phishing/campaigns/[id]/triage-note route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural twin of the classify route: requirePermission('phishing', 'analyze') gate, UUID guard, campaign-exists 404 check, delegates to generateAndPostTriageNote and returns its result verbatim (note text + per-ticket posted/error status, D-06). No audit-event write — deferred per 21-CONTEXT.md. --- .../campaigns/[id]/triage-note/route.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/triage-note/route.ts diff --git a/app/api/phishing/campaigns/[id]/triage-note/route.ts b/app/api/phishing/campaigns/[id]/triage-note/route.ts new file mode 100644 index 0000000..50ce398 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/triage-note/route.ts @@ -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 } + ); + } +} From 950227ea4e0a9a7b757910093b73b93684015fc9 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:15:07 -0400 Subject: [PATCH 4/5] docs(21-02): add plan summary for triage-note service and endpoint Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- .../21-autotask-triage-note/21-02-SUMMARY.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .planning/phases/21-autotask-triage-note/21-02-SUMMARY.md diff --git a/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md b/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md new file mode 100644 index 0000000..75489ce --- /dev/null +++ b/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md @@ -0,0 +1,103 @@ +--- +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' ``, ``, and `` 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* From fff7d97c5d604dc727a3be3330a128ac0ab6b902 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:15:25 -0400 Subject: [PATCH 5/5] docs(21-02): append self-check result to summary Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- .planning/phases/21-autotask-triage-note/21-02-SUMMARY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md b/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md index 75489ce..359d29c 100644 --- a/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md +++ b/.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md @@ -101,3 +101,7 @@ None - no external service configuration required beyond what Phases 17-20 alrea --- *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.