- Alias campaigns table as c, add correlated subquery for the earliest
linked report's ticket_id so the list page can navigate a row click
straight to /phishing/tickets/{firstReportTicketId}
- Additive only: count query, limit/offset, requirePermission gate, and
the { items, total, limit, offset } envelope all unchanged
- New GET /api/phishing/tickets/{ticket_id}/campaign wraps
resolveTicketToCampaign() from plan 22-01
- requirePermission('phishing','read') gate, Number.isFinite param
validation, D-07: found:false at 200 (not 404) for untriaged tickets
- Add 22-04-SUMMARY.md documenting the two components delivered
- Log pre-existing unrelated itglue-search.test.ts failures to
deferred-items.md (out of scope for this plan)
- Mark REVIEW-02, REVIEW-04 complete in REQUIREMENTS.md
- components/phishing/evidence-card.tsx renders Headers, URLs,
Attachments, Body preview, and Blast Radius tabs for a selected
message
- Body preview renders inside a <pre className="whitespace-pre-wrap">
as plain JSX text only, never via a raw-HTML injection prop
- URLs tab delegates to UrlList (D-09 inert copy-only)
- Blast radius renders explicit unavailable-state copy or a
matched/delivered/held/rejected/clicked stat row + per-recipient
table when ok
- CardTitle explicitly overridden with font-bold per UI-SPEC typography
- Merges reports/classifications/audit-events into one ascending list
(relies on server ordering, no client-side sort)
- Per-kind icon/label/tint: FileText for reports, Sparkles tinted by
verdict for classifications, event_type table for audit rows
(remediation_approved/completed, campaign_marked_false_positive,
campaign_classified, humanized fallback for anything else)
- 8px rail dot + border-l connector per UI-SPEC Timeline Spec
- Discriminated union TimelineEntry with report/classification/audit variants
- Ascending sort by createdAt with stable report<classification<audit tie-break
- Covers ascending chronological sort across reports/classifications/audit
- Asserts discriminant kind + source fields per variant, plus stable tie-break order
- Pure switch over no_action/warn_user/block_sender/purge_message/
reset_password/isolate_endpoint/disable_forwarding_rule
- Unknown/future action types fall through to {} rather than throwing
- Renders latest classification verdict/confidence/summary/reasons
- Recommended-action chips (informational, no checkboxes)
- Requires-approval warning Alert when requiresApproval is true
- Reclassify button gated on hasPermission(role, 'phishing', 'analyze')
- Returns null when classification is missing (empty-state handled by plan 06)
- npx shadcn add tooltip generates components/ui/tooltip.tsx (official
registry, no npm dependency added)
- components/phishing/url-list.tsx renders extracted URLs as inert
<code> text with copy-to-clipboard only — no <a>/href, no <Link>,
no navigating onClick per D-09
- Pure lookup: reports row for a ticket id -> found/reportId/campaignId/ticketNumber
- Parameterized query only (WHERE ticket_id = $1), no requirePermission/NextResponse
Checker flagged 4 distinct font weights in use together (400/500/600/700)
against a 2-weight max. Consolidate to 400 (Body+Label, differentiated by
size/color) and 700 (Heading+Display, differentiated by size only) —
requires an explicit font-bold override on CardTitle since shadcn's
default is font-semibold. Also addresses two non-blocking flags:
"Reclassify" -> "Reclassify ticket", and aria-label on icon-only Copy
buttons (URL/hash).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
Composes existing Pulse design tokens into a phase-specific contract:
page layout for the ticket-scoped review page and new campaigns list
page, evidence/timeline/classification display specs, and the
approve/remediate/mark-false-positive action-area contract (D-03..D-06
disabled-state and confirmation copy).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
Structural twin of the classify route: requirePermission('phishing',
'analyze') gate, UUID guard, campaign-exists 404 check, delegates to
generateAndPostTriageNote and returns its result verbatim (note text +
per-ticket posted/error status, D-06). No audit-event write — deferred
per 21-CONTEXT.md.
GREEN: generateAndPostTriageNote(campaignId) gathers linked reports,
most-recent classification (NUMERIC confidence coerced to a JS number),
current remediation_actions, and real url indicators via the
reports->messages->indicators join; renders the sanitized note via Plan
01's formatTriageNote, then posts one internal TicketNotes write per
linked ticket with independent per-ticket error capture so a single
write failure never aborts the call (D-05) and note text is always
returned (D-06).
- formatTriageNote renders verdict/confidence/summary/reasons/blast-radius
(both branches)/recommended actions/current remediation state as prose
- Routes indicator URLs through sanitizeUrl and the whole assembled output
through sanitizeNoteText before returning
- Handles null verdict/confidence gracefully
- Exports TriageNoteEvidence interface for Plan 02
- All 9 formatter tests pass