Bundles several in-progress efforts that were sitting uncommitted: - User queue-preferences (migration 087, API route, popover component) - QBO invoice soft-delete (migration 088) and AR diagnostics route - Dashboard/mobile engagement route and page adjustments - Docker Compose log-rotation config - One-off ticket/RMM investigation scripts (scripts/) - Planning docs: phase verification/pattern notes, mobile shell design spec - .gitignore: exclude local scratch financial/inventory data and Claude Code worktree/local-settings runtime state (never meant for version control) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
17 KiB
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):
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):
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):
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):
} 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):
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:
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):
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):
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):
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):
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:
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):
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:
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:
- The module-level "why" comment convention — state plainly what must
never leak and why, mirroring lines 1-16 of
itglue-redact.ts. - The exported-for-tests + pure-function shape — a small
sanitizeIndicatorValue(value: string, type: string): string(or similar) function, unit-testable in isolation, same asredact()/isSensitiveKey()are exported standalone initglue-redact.tslines 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.
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