---
phase: 21-autotask-triage-note
plan: 02
subsystem: api
tags: [phishing-triage, autotask, triage-note, vitest, api-route]

# Dependency graph
requires:
  - phase: 21-autotask-triage-note
    plan: 01
    provides: "formatTriageNote(evidence) + TriageNoteEvidence contract, sanitizeUrl/sanitizeNoteText"
  - phase: 17-mimecast-blast-radius-lookup
    provides: getBlastRadius(input) -> BlastRadiusResult
  - phase: 19-classification-engine
    provides: classifications table (verdict/confidence/summary/reasons/recommended_actions)
  - phase: 20-remediation-approval-audit-safety
    provides: remediation_actions status lifecycle rows
provides:
  - "generateAndPostTriageNote(campaignId) orchestrator (lib/services/triage-note-service.ts)"
  - "POST /api/phishing/campaigns/[id]/triage-note endpoint"
affects: [22-approval-ui-livelink-addressable-campaign-review-and-approve]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "Per-ticket try/catch INSIDE the write loop (not around it) so one Autotask write failure never aborts remaining writes (D-05)"
    - "NUMERIC confidence column defensively coerced with both a SQL ::float8 cast and a runtime Number() wrap, so the number|null contract holds even against a string-returning mock/driver"
    - "Service always returns generated note text regardless of write outcome (D-06) — never a silent partial rollback"

key-files:
  created:
    - lib/services/triage-note-service.ts
    - lib/services/triage-note-service.test.ts
    - app/api/phishing/campaigns/[id]/triage-note/route.ts
  modified: []

key-decisions:
  - "Blast-radius sender/recipient inputs use best-available data from the bounded reports query (title only, sender/recipient left empty) rather than adding extra contact/message-header queries — getBlastRadius degrades gracefully (never throws) on sparse input, and the plan's evidence-gathering SQL is limited to the four queries it specifies (reports, classifications, remediation_actions, indicators)"
  - "Verdict is cast from the classifications.verdict TEXT column to TriageNoteEvidence['verdict'] rather than validated against the union at runtime — consistent with how campaign-classifier.ts/classify route already trust this column"

requirements-completed: [NOTE-01]

# Metrics
duration: ~11min
completed: 2026-07-16
---

# Phase 21 Plan 02: Triage-Note Service + Endpoint Summary

**`generateAndPostTriageNote(campaignId)` gathers current campaign evidence (linked reports, real url indicators via the reports→messages→indicators join, most-recent classification with a numeric-coerced confidence, current remediation state, fresh blast radius), renders it via Plan 01's sanitized formatter, and posts one internal Autotask TicketNote per linked ticket with independent per-ticket failure capture — exposed via `POST /api/phishing/campaigns/{id}/triage-note`.**

## Performance

- **Duration:** ~11 min
- **Started:** 2026-07-16T16:03:00Z (approx.)
- **Completed:** 2026-07-16T16:14:33Z
- **Tasks:** 2 completed
- **Files modified:** 3 (all newly created)

## Accomplishments
- `generateAndPostTriageNote(campaignId)` orchestrates: linked-reports read, most-recent classification read (with `confidence::float8` SQL cast + defensive `Number()` runtime coercion), current `remediation_actions` state, and a real `reports → messages → indicators` join filtered to `indicator_type = 'url'` for extracted indicator URLs
- Fresh `getBlastRadius()` lookup per call (D-04 — always current, not frozen at classify-time)
- Builds a `TriageNoteEvidence` object and calls Plan 01's `formatTriageNote()` to get the sanitized note text
- Posts one `createEntity('TicketNotes', { ticketID, title, description, noteType: 1, publish: 1 })` write per linked ticket, each in its OWN try/catch (D-05) — a single ticket's failure is captured as `{ ticketId, posted: false, error }` without aborting the remaining writes
- `noteText` is always returned regardless of write outcome (D-06) — verified by a test where the only linked ticket's write fails and `noteText` is still non-empty
- `POST /api/phishing/campaigns/[id]/triage-note` — structural twin of `classify/route.ts`: `requirePermission('phishing', 'analyze')` gate, UUID guard (400), campaign-exists check (404), delegates to the service, returns its result verbatim, no audit-event write (deferred per CONTEXT.md)

## Task Commits

Task 1 followed RED → GREEN (TDD):
1. **Task 1: triage-note-service** - `34a0269` (test: RED, verified failing — module did not exist) → `2d410f8` (feat: GREEN, all 8 tests pass)
2. **Task 2: POST route** - `e3a9cb5` (feat, no TDD gate — `type="auto"` without `tdd="true"`)

_TDD gate compliance: Task 1 has a `test(...)` commit followed by a `feat(...)` commit; RED was verified by temporarily removing the implementation file and confirming the suite failed with "Cannot find module" before restoring it and re-running to GREEN. No refactor step was needed._

## Files Created/Modified
- `lib/services/triage-note-service.ts` - `TriageNotePostResult`/`TriageNoteResult` interfaces + `generateAndPostTriageNote(campaignId)` orchestrator
- `lib/services/triage-note-service.test.ts` - 8 Vitest cases: all-succeed write loop, one-of-three-fails isolation (D-05), note-text-always-returned (D-06), sanitized-URL-flows-into-note, zero-url-indicators, string-confidence coercion, zero-linked-reports, no-classification-row
- `app/api/phishing/campaigns/[id]/triage-note/route.ts` - `POST` handler: auth gate, UUID guard, 404, delegate, verbatim return, 500 catch

## Decisions Made
- Blast-radius sender/recipient use best-available data (title only) from the bounded reports query rather than adding extra contact/message-header lookups — matches the plan's literal four-query evidence-gathering scope and `getBlastRadius()`'s documented graceful-degradation behavior on sparse input (never throws).
- `classifications.verdict` (a TEXT column) is cast to `TriageNoteEvidence['verdict']` without an additional runtime validation step, consistent with how the existing classify route and campaign-classifier.ts already trust this column's values.
- Route uses `phishing:analyze` (not `phishing:approve`) per CONTEXT.md's explicit lean — note generation is informational, not a state-changing security decision.

## Deviations from Plan

None - plan executed exactly as written. Both tasks' `<behavior>`, `<action>`, and `<acceptance_criteria>` blocks were implemented as specified; no architectural changes, no missing critical functionality found, no blocking issues encountered.

## Issues Encountered
None.

## User Setup Required
None - no external service configuration required beyond what Phases 17-20 already established (Autotask/Mimecast env vars, if configured).

## Next Phase Readiness
- `generateAndPostTriageNote()` and the new `POST /api/phishing/campaigns/{id}/triage-note` endpoint are ready for Phase 22's LiveLink approval UI to call as its "send triage note" action.
- No blockers.

---
*Phase: 21-autotask-triage-note*
*Completed: 2026-07-16*

## Self-Check: PASSED

All created files (`lib/services/triage-note-service.ts`, `lib/services/triage-note-service.test.ts`, `app/api/phishing/campaigns/[id]/triage-note/route.ts`) and task commit hashes (`34a0269`, `2d410f8`, `e3a9cb5`) verified present on disk / in git history.
