# Phase 21: Autotask Triage Note - Pattern Map **Mapped:** 2026-07-16 **Files analyzed:** 2 new (route + service), 1 optional (service test) **Analogs found:** 2 / 2 (both exact/near-exact structural matches) ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `app/api/phishing/campaigns/[id]/triage-note/route.ts` (new) | controller (route) | request-response | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact | | `lib/services/triage-note-service.ts` (new — planner may name differently) | service | CRUD (read-many) + file/external-write (Autotask `TicketNotes` POST per linked ticket) | `lib/services/campaign-classifier.ts` (evidence gathering half) + `lib/services/workflow-engine.ts` `runAiTroubleshooting` (Autotask write half) | role-match (composite — no single existing file does both halves) | | `lib/services/triage-note-service.test.ts` (optional, if planner follows sibling-test convention) | test | n/a | `lib/services/remediation-service.test.ts` / `lib/services/campaign-classifier.test.ts` | role-match | ## Pattern Assignments ### `app/api/phishing/campaigns/[id]/triage-note/route.ts` (controller, request-response) **Analog:** `app/api/phishing/campaigns/[id]/classify/route.ts` (full file read — 67 lines) This is a near-identical structural twin. Copy the whole shape: UUID guard, `requirePermission`, campaign-exists pre-check, service delegation, try/catch with typed error branches, `console.error` with a `[PHISHING-*]` tag. **Imports pattern** (lines 12-16): ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requirePermission } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; import { classifyCampaign } from '@/lib/services/campaign-classifier'; import { writeAuditEvent } from '@/lib/services/phishing-audit'; ``` For the new route, swap the service import for the new triage-note service export (e.g. `generateAndPostTriageNote`) — `writeAuditEvent` is optional here (no explicit audit event is required by CONTEXT.md D-01..D-06 for this phase; if the planner wants one, `remediation-service.ts`'s in-transaction audit pattern is the reference — see Shared Patterns below). **UUID guard + permission gate** (lines 18, 20-32): ```typescript 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 { session, 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 }); } ``` Per CONTEXT.md's deferred discretion note, `'analyze'` (not `'approve'`) is the recommended permission tier — matches `classify`'s tier since this is informational, not a destructive state change. **Campaign-exists pre-check + service delegation + response** (lines 34-43, 58): ```typescript 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 }); } const result = await classifyCampaign(id); // -> generateAndPostTriageNote(id) return NextResponse.json(result); ``` D-06's response shape (note text + per-ticket `posted`/error status list) should be returned directly as the service's return value — no reshaping needed in the route, matching how `classify`/`approve`/`remediate` all just `NextResponse.json(result)` the service's return type verbatim. **Error handling pattern** (lines 59-65 — the ONLY error branch this route needs, since D-05 says individual write failures are captured *inside* the service's return value, not thrown): ```typescript } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); return NextResponse.json( { error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 } ); } } ``` Rename the log tag (e.g. `[PHISHING-TRIAGE-NOTE]`) and message. This catch block should only ever fire for a whole-request failure (e.g. campaign evidence-gathering itself throws) — NOT for a single ticket's Autotask write failing, which D-05/D-06 require to be caught per-ticket inside the service and reported in the 200 response body instead. **Optional: typed-error branches** if the service throws domain errors (see `approve/route.ts` lines 63-69 for the pattern, not strictly needed here since this phase has no validation-conflict states like approve/remediate do): ```typescript if (err instanceof RemediationValidationError) { return NextResponse.json({ error: err.message }, { status: 400 }); } ``` --- ### `lib/services/triage-note-service.ts` (service, CRUD-read + external-write) No single existing file does both halves this service needs, so it composes two analogs: **evidence gathering** (read side, copy shape from `campaign-classifier.ts`'s `gatherCampaignEvidence`) and **Autotask note write** (write side, copy verbatim from `workflow-engine.ts`'s `runAiTroubleshooting`). **Imports pattern** — composite of `campaign-classifier.ts` (lines 17-19) and the Autotask factory used across `workflow-engine.ts`: ```typescript import { postgresClient } from './postgres-client'; import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius'; import { getAutotaskClient } from './autotask-factory'; import type { TicketNote } from '@/lib/types/autotask'; ``` **Read-side pattern — bulk-fetch linked reports, then classification + remediation state** (`campaign-classifier.ts` lines 270-296, adapted; also see `app/api/phishing/campaigns/[id]/route.ts` lines 84-96 for the same `reports WHERE campaign_id = $1 ORDER BY created_at ASC` bulk-fetch shape used a third time in this codebase): ```typescript const reportsRes = await postgresClient.query( `SELECT r.id::text AS id, r.ticket_id::text AS ticket_id, r.ticket_number, r.title, r.created_at::text AS created_at, c.email_address AS requester_email FROM reports r LEFT JOIN contacts c ON c.id = r.requester_contact_id WHERE r.campaign_id = $1 ORDER BY r.created_at ASC`, [campaignId] ); ``` **Most-recent classification** (`remediation-service.ts` lines 89-96 — same `ORDER BY created_at DESC LIMIT 1` idiom used for "current" state per D-04): ```typescript const classificationRes = await client.query( `SELECT recommended_actions FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`, [campaignId] ); ``` For the triage note, select the full row (`verdict, confidence, summary, reasons, recommended_actions, requires_approval, created_at`), not just `recommended_actions` — D-04 requires verdict/confidence/summary/reasons in the note. **Current remediation_actions state** (D-04 — "proposed only if no operator has acted, else approved/completed rows") — same table/columns `remediation-service.ts` already reads/writes (lines 133-148, 175-179): ```typescript const remediationRes = await postgresClient.query( `SELECT id::text, action_type, status, approved_by, approved_at::text FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`, [campaignId] ); ``` **Blast radius — reuse the exact `getBlastRadius()` call shape** from `campaign-classifier.ts` lines 332-351 (D-04/Claude's-Discretion: planner may call fresh or reuse Phase 19's persisted `reasons` — either way this is the call signature to copy if calling fresh): ```typescript const blastRadius = await getBlastRadius({ sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '', recipient: primaryReport.requesterEmail ?? '', subject: primaryMessage?.subject ?? primaryReport.title ?? '', dateWindow: { start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000), end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000), }, }); // BlastRadiusResult is a discriminated union — status: 'ok' | 'unavailable'. // D-04 requires an explicit "unavailable" string in the note when this // branch is hit, never a silent omission. ``` **Write-side pattern — one `createEntity('TicketNotes', ...)` call per linked ticket, copied verbatim from `workflow-engine.ts` lines 581-589**: ```typescript const client = getAutotaskClient(); await client.createEntity('TicketNotes', { ticketID: ticket.id, // -> report.ticketId for each linked report (D-01) title: 'Troubleshooting Steps (Auto-Generated)', // -> e.g. 'Phishing Triage Summary' description: steps, // -> the generated sanitized note text noteType: 1, // Internal publish: 1, }); ``` `TicketNote` interface for reference (`lib/types/autotask.ts` lines 185-196): ```typescript export interface TicketNote { id: number; ticketID: number; title?: string; description?: string; noteType?: number; publish?: number; creatorResourceID?: number; creatorType?: number; lastActivityDate?: string; createDateTime?: string; } ``` `createEntity` generic signature (`lib/services/autotask-client.ts` lines 175-189) — throws `Error('Failed to create entity')` if Autotask's response has no `item`, and lets network/HTTP errors from `makeApiCall` propagate uncaught. **This is exactly the failure mode D-05 requires the service to catch per-ticket** — wrap each `createEntity` call in its own try/catch inside a loop over linked tickets, not one try/catch around the whole loop: ```typescript const ticketResults: Array<{ ticketId: string; posted: boolean; error?: string }> = []; for (const report of reports) { try { await client.createEntity('TicketNotes', { ticketID: Number(report.ticketId), title: 'Phishing Triage Summary', description: noteText, noteType: 1, publish: 1, }); ticketResults.push({ ticketId: report.ticketId, posted: true }); } catch (err) { console.error('[TRIAGE-NOTE] Failed to post note to ticket', report.ticketId, err); ticketResults.push({ ticketId: report.ticketId, posted: false, error: err instanceof Error ? err.message : 'Unknown error', }); } } ``` **Sanitization precedent** — `lib/services/analyzer/itglue-redact.ts` (full file, 65 lines) is the spirit-reference named in CONTEXT.md, though it redacts by KEY NAME across an arbitrary object tree (IT Glue documents), which doesn't map directly onto this phase's need (truncating URL query strings inside plain prose text). Two concrete things to actually copy: 1. The **module-level "why" comment convention** — state plainly what must never leak and why, mirroring lines 1-16 of `itglue-redact.ts`. 2. The **exported-for-tests + pure-function** shape — a small `sanitizeIndicatorValue(value: string, type: string): string` (or similar) function, unit-testable in isolation, same as `redact()`/`isSensitiveKey()` are exported standalone in `itglue-redact.ts` lines 26-28 and 62-64. For URL truncation specifically there is no existing analog in this codebase — this is genuinely new logic (strip query string via `new URL(value).origin + new URL(value).pathname`, wrapped in try/catch for malformed URLs). **No-op / synthesized-value pattern for missing evidence** — copy `mimecast-blast-radius.ts`'s discriminated union (`status: 'ok' | 'unavailable'`, never a thrown error for a missing/misconfigured integration) as the model for how the note text should render "blast radius data unavailable" rather than omitting the section — same spirit as `campaign-classifier.ts` line 350's `{ status: 'unavailable', reason: 'not_configured' }` synthesis when there are no linked reports at all. --- ### `lib/services/triage-note-service.test.ts` (test, optional) **Analog:** `lib/services/remediation-service.test.ts` and `lib/services/campaign-classifier.test.ts` (not read in full — file names only, per early-stopping guidance; both are existing Vitest suites under `lib/services/` that test service functions directly against a real/fixture Postgres, following the project's stated test coverage: `lib/services/**` is covered). Structure to follow: mock or seed `campaigns`/`reports`/ `classifications`/`remediation_actions` rows, mock `getAutotaskClient()` (or the whole `autotask-factory` module) to assert `createEntity` was called once per linked ticket with the expected `ticketID`/`description`, and assert the per-ticket failure path (D-05/D-06) when a mocked `createEntity` rejects for one of several tickets. ## Shared Patterns ### Auth/Permission gate **Source:** `lib/auth-utils.ts` lines 51-71 (`requirePermission`), used identically by `classify/route.ts` line 24, `approve/route.ts` line 28, `remediate/route.ts` line 27, `route.ts` (GET) line 62. **Apply to:** the new triage-note route. ```typescript const { session, error } = await requirePermission('phishing', 'analyze'); if (error) return error; ``` `lib/permissions.ts` line 33/50/64/78 confirms `'analyze'` is already granted to admin/super-admin/user roles (only the read-only-ish role at line 78 lacks it) — no new permission statement needed. ### UUID param validation **Source:** identical `UUID_RE` regex + early-400 pattern in all four existing `campaigns/[id]/*` routes (`classify`, `approve`, `remediate`, base `route.ts`). **Apply to:** the new triage-note route — copy the exact regex, don't re-derive it. ### Campaign-exists pre-check before service delegation **Source:** `classify/route.ts` lines 34-41, `approve/route.ts` lines 52-59, `remediate/route.ts` lines 40-46 — all three query `SELECT id FROM campaigns WHERE id = $1` and return 404 before calling their service function. **Apply to:** the new triage-note route, same shape. ### Error response shape **Source:** every phishing route's catch block: `NextResponse.json({ error: '...', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 })` with a `console.error('[PHISHING-] ...')` line immediately before. **Apply to:** the new route's outer catch (whole-request failures only — see D-05 note above about per-ticket failures NOT using this branch). ### Safe Autotask ticket-note write **Source:** `lib/services/workflow-engine.ts` lines 581-589 (`runAiTroubleshooting`), backed by `lib/services/autotask-client.ts` `createEntity` (lines 175-189) and `lib/services/autotask-factory.ts` `getAutotaskClient()`. **Apply to:** the new service's write loop — `noteType: 1` (Internal), `publish: 1` (All Autotask Users, still non-portal per `AUTOTASK_API_GUIDE.md` line 365) are the existing codebase's only precedent values; reuse them unless the planner has a specific reason to pick `publish: 2` (Internal Users Only — even more restrictive, also non-portal). ### Audit trail (optional — not required by CONTEXT.md for this phase) **Source:** `lib/services/phishing-audit.ts` (full file, 55 lines) — `writeAuditEvent({ campaignId, actor, eventType, payload }, client?)`. Used by every state-*changing* action (classify/approve/remediate/false-positive). This phase is read+external-write, not a Postgres state change, so an audit row is NOT strictly required by any D-0x decision — CONTEXT.md's Deferred Ideas section explicitly puts "persisting sent-note history" out of scope. If the planner still wants a lightweight audit trail of *when* a triage note was requested (not full content), this is the write shape to reuse; `client` param is optional so it can be called standalone (no transaction needed since there's no corresponding state row to keep atomic with). ## No Analog Found | File | Role | Data Flow | Reason | |------|------|-----------|--------| | URL/text sanitization helper (e.g. `lib/services/triage-note-sanitize.ts`, if split out) | utility | transform | No existing codebase function truncates URL query strings or formats human-readable prose from structured evidence — `itglue-redact.ts` redacts by object key name (a different technique for a different data shape); this is genuinely new logic per CONTEXT.md's "Claude's Discretion" section. | | Note-text template/formatter | utility | transform | No existing "build human-readable prose from campaign+classification+remediation rows" function exists anywhere in the codebase — closest precedent is `campaign-classifier.ts`'s one-line `summary` string (line 487), which is far shorter than what D-04 requires here. | ## Metadata **Analog search scope:** `app/api/phishing/**`, `lib/services/campaign-classifier.ts`, `lib/services/remediation-service.ts`, `lib/services/campaign-grouping-service.ts`, `lib/services/phishing-audit.ts`, `lib/services/workflow-engine.ts`, `lib/services/autotask-client.ts`, `lib/services/autotask-factory.ts`, `lib/services/mimecast-blast-radius.ts`, `lib/services/analyzer/itglue-search.ts`, `lib/services/analyzer/itglue-redact.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`, `lib/types/autotask.ts`, `migrations/097_phishing_triage_schema.sql`. **Files scanned:** 15 **Pattern extraction date:** 2026-07-16