wulf-pulse/.planning/phases/21-autotask-triage-note/21-PATTERNS.md
lorentz 672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
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
2026-07-18 06:34:57 -04:00

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 precedentlib/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.

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