365 lines
17 KiB
Markdown
365 lines
17 KiB
Markdown
|
|
# 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
|