# 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<ReportDbRow>(
  `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<ClassificationRow>(
  `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<RemediationActionRow>(
  `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<T>` 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-<ACTION>] ...')` 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<T>` (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
