---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
plan: 06
type: execute
wave: 3
depends_on: ["22-02", "22-03", "22-04", "22-05"]
files_modified:
  - app/phishing/tickets/[ticketId]/page.tsx
  - app/phishing/page.tsx
  - components/navigation/app-navigation.tsx
autonomous: false
requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
must_haves:
  truths:
    - "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
    - "The review page uses Pulse's full navigation shell — AppNavigation, PageHeader, container mx-auto px-6 py-6 — same chrome as /admin, /analyzer (D-01)"
    - "The review page renders classification, action area, evidence, and timeline for a CLASSIFIED grouped campaign; a 'Not yet classified' empty state with a 'Classify this campaign' CTA for a grouped-but-unclassified campaign (the default post-grouping state); a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
    - "A grouped campaign with zero classifications (the default state right after detection/grouping, since classification is never auto-triggered) never crashes — ClassificationCard and ActionAreaCard are not rendered, a Classify CTA (POST /classify) is offered instead, and evidence + timeline still display"
    - "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state (D-04)"
    - "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
    - "A 'Phishing' nav entry links to /phishing"
    - "Phase completion is gated on a manual confirmation that a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the documented ticket_number fallback is applied)"
  artifacts:
    - path: "app/phishing/tickets/[ticketId]/page.tsx"
      provides: "the ticket-scoped LiveLink review page (REVIEW-01..06)"
      min_lines: 60
    - path: "app/phishing/page.tsx"
      provides: "minimal campaigns list page (D-00)"
      min_lines: 30
    - path: "components/navigation/app-navigation.tsx"
      provides: "Phishing nav entry (D-02)"
      contains: "/phishing"
  key_links:
    - from: "app/phishing/tickets/[ticketId]/page.tsx"
      to: "/api/phishing/tickets/{ticketId}/campaign then /api/phishing/campaigns/{id}"
      via: "two-step fetch in load()"
      pattern: "tickets/.+/campaign"
    - from: "app/phishing/tickets/[ticketId]/page.tsx"
      to: "/api/phishing/campaigns/{id}/classify"
      via: "Classify CTA in the grouped-but-unclassified branch"
      pattern: "campaigns/.+/classify"
    - from: "app/phishing/page.tsx"
      to: "/phishing/tickets/{firstReportTicketId}"
      via: "router.push on row click"
      pattern: "firstReportTicketId"
---

<objective>
Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a state machine (loading / not-triaged / ungrouped / ready / error) where the `ready` render additionally branches on whether the grouped campaign has been classified yet, composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable. A final blocking manual-verification checkpoint confirms the LiveLink numeric-ticket-id assumption against a real Autotask tenant (REVIEW-01, per 22-RESEARCH resolved Open Question).

Purpose: This is the integration layer — every other plan's output is composed here into the operator-facing surfaces.
Output: 2 pages + 1 nav edit + 1 manual-verification gate.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md

<interfaces>
Routes consumed (plan 02 + existing):
  GET /api/phishing/tickets/{ticketId}/campaign -> { found:boolean; reportId?; campaignId?:string|null; ticketNumber? }
  GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports(created_at ASC) + messages(headers/urls/attachments/bodyPreview) + indicators + classifications(created_at DESC) + remediationActions(+completedAt) + auditEvents + blastRadius + timeline)
  GET /api/phishing/campaigns?limit&offset -> { items:[{ id, campaignKey, status, reportCount, firstSeenAt, lastSeenAt, firstReportTicketId }], total, limit, offset }
  POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA — detect+parse+group; does NOT classify)
  POST /api/phishing/campaigns/{id}/classify (grouped-but-unclassified CTA + ClassificationCard Reclassify — the ONLY trigger of classifyCampaign; detection/grouping never calls it)

Latest-classification derivation: the detail route returns `classifications` (plural, created_at DESC). The single latest classification the cards consume is `campaignDetail.classifications[0] ?? null`. A newly-grouped campaign has `classifications: []` → `null`.

Card signatures (plans 03/04/05):
  ClassificationCard({ campaignId, classification, onReclassified })
  ActionAreaCard({ campaignId, classification, remediationActions, campaignStatus, campaignUpdatedAt, evidence:{requesterEmail,senderEmail,senderDomain,messageId}, onActionComplete })
  EvidenceCard({ messages, blastRadius })
  TimelineCard({ timeline })

Components consumed:
  components/phishing/{classification-card,action-area-card,evidence-card,timeline-card}.tsx (plans 03/04/05)
  components/admin/DataTable.tsx (Column<TData>[], data, totalCount, page, pageSize, onPageChange, onRowClick)
  components/ui/{empty-state,alert,status-badge,skeleton-helpers}.tsx, components/navigation/page-header.tsx

Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX8 entry (title/href/icon/description) is the exact shape; insert the new item immediately after PAX8, before "Backup Status". Add ShieldAlert to the lucide-react import block. No role gate (every role has phishing:read).
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Ticket-scoped review page (REVIEW-01, REVIEW-05, REVIEW-06)</name>
  <files>app/phishing/tickets/[ticketId]/page.tsx</files>
  <read_first>
    - app/analyzer/reports/[id]/page.tsx (use(params) + fetch + loading/error early-return + Card-stack layout idiom)
    - components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here + their exact prop signatures)
    - lib/auth-client.ts (useSession) + lib/permissions.ts (hasPermission — for gating the Classify CTA on phishing:analyze, mirroring the Reclassify button)
    - components/ui/empty-state.tsx, components/ui/alert.tsx, components/ui/skeleton-helpers.tsx, components/navigation/page-header.tsx
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States — INCLUDING the "Grouped, not yet classified" row, Copywriting Contract — INCLUDING the "Not yet classified" copy)
  </read_first>
  <action>
    Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): `<PageHeader title="Ticket #{ticketNumber}" description={humanized campaign status} breadcrumbs={['Phishing','Ticket #{ticketNumber}']} />` inside `<main className="container mx-auto px-6 py-6 space-y-6">` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`.

    Render per state:
    - loading → `SkeletonHeader` + 3× `SkeletonCard`.
    - not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()).
    - ungrouped (D-08) → info-tone `<Alert>` with the D-08 copy followed by `<EvidenceCard messages={standaloneMessages} blastRadius={...} />` alone (no classification/action/timeline cards).
    - error → destructive `<Alert>` with the error copy + "Retry" button re-running load().
    - ready → FIRST derive the single latest classification: `const classification = campaignDetail.classifications[0] ?? null;` (the detail route returns `classifications` DESC-ordered; a freshly-grouped campaign returns `[]` → null). BRANCH on `classification`:
        (a) `classification == null` (grouped-but-not-yet-classified — the DEFAULT state, because detection/grouping never auto-triggers classification): render a full-width `<EmptyState>` (icon Sparkles, title "Not yet classified", body per Copywriting Contract, CTA "Classify this campaign" that POSTs `/api/phishing/campaigns/${campaignId}/classify` then re-runs load()). Gate the CTA button on `hasPermission(role, 'phishing', 'analyze')` (role from `useSession()`, `(session?.user as {role?:string})?.role ?? 'user'`) — HIDDEN when absent, mirroring ClassificationCard's Reclassify gate; when hidden, still show the empty-state text so a read-only operator understands the state. Do NOT render `<ClassificationCard>` or `<ActionAreaCard>` in this branch (both require a non-null classification). Then render the read-only detail below: `<div className="grid gap-6 lg:grid-cols-2"><EvidenceCard messages={campaignDetail.messages} blastRadius={campaignDetail.blastRadius} /><TimelineCard timeline={campaignDetail.timeline} /></div>` (evidence + timeline exist even without a classification).
        (b) `classification != null`: render in order, enumerating props explicitly for ALL four cards:
          `<ClassificationCard campaignId={campaignId} classification={classification} onReclassified={load} />` (full width),
          `<ActionAreaCard campaignId={campaignId} classification={classification} remediationActions={campaignDetail.remediationActions} campaignStatus={campaignDetail.status} campaignUpdatedAt={campaignDetail.updatedAt} evidence={evidence} onActionComplete={load} />` (full width),
          then `<div className="grid gap-6 lg:grid-cols-2"><EvidenceCard messages={campaignDetail.messages} blastRadius={campaignDetail.blastRadius} /><TimelineCard timeline={campaignDetail.timeline} /></div>`.

    Derive the ActionAreaCard `evidence` object from the PRIMARY (earliest) report + its message — use `campaignDetail.reports[0]` (same `reports[0]`/earliest-report definition as plan 22-02's server-side `blastRadius` derivation, which orders reports `created_at ASC`, so the client evidence object and the server blast-radius agree on which report is "primary") and its message headers: `{ requesterEmail: reports[0].requesterEmail, senderEmail: primaryMessage.headers.from.email, senderDomain: <derived from senderEmail>, messageId: primaryMessage.messageId }`.

    Pass `load` as `onActionComplete`/`onReclassified` so every action (approve/remediate/mark-false-positive/reclassify/classify) refetches (D-04, no optimistic mutation). Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01).
  </action>
  <verify>
    <automated>grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "classifications\[0\]" app/phishing/tickets/[ticketId]/page.tsx && grep -q "campaigns/\${campaignId}/classify" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "not-triaged" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "ungrouped" ...` both succeed (D-07/D-08 states present)
    - `grep -q "classifications\[0\]" app/phishing/tickets/[ticketId]/page.tsx` succeeds (single latest classification derived as `classifications[0] ?? null`, Warning 3)
    - `grep -q "Not yet classified" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "campaigns/\${campaignId}/classify" ...` both succeed (grouped-but-unclassified branch + Classify CTA — Blocker 1)
    - `grep -q "/analyze" app/phishing/tickets/[ticketId]/page.tsx` succeeds (D-07 CTA)
    - `grep -q "onActionComplete" app/phishing/tickets/[ticketId]/page.tsx` succeeds and points at the refetch function (D-04)
    - `grep -c "searchParams\|token=" app/phishing/tickets/[ticketId]/page.tsx` returns 0 (no separate auth scheme — REVIEW-01)
    - ActionAreaCard is rendered ONLY in the `classification != null` branch (never for a null classification) — verified by the branch guard; and its props include `classification={classification}` (non-null in that branch)
    - All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported; each is rendered with an explicit prop list per the card signatures above (Warning 3)
    - `npx tsc --noEmit --pretty` reports no error in this file
  </acceptance_criteria>
  <done>Review page resolves ticket→campaign; renders loading/not-triaged/ungrouped/error plus a `ready` render that branches into grouped-but-unclassified (Classify CTA + evidence/timeline, no crash) vs. classified (all four cards with explicit props); refetches after every action; Better-Auth-session-only.</done>
</task>

<task type="auto">
  <name>Task 2: Campaigns list page (D-00) + Phishing nav entry (D-02)</name>
  <files>app/phishing/page.tsx, components/navigation/app-navigation.tsx</files>
  <read_first>
    - app/admin/data-browser/companies/page.tsx (DataTable + pagination-state + row-click-navigate idiom)
    - components/admin/DataTable.tsx (confirmed props: columns/data/totalCount/page/pageSize/onPageChange/onRowClick)
    - components/ui/empty-state.tsx, components/ui/status-badge.tsx, components/navigation/page-header.tsx
    - components/navigation/app-navigation.tsx (the navigationItems array + the PAX8 entry shape + the lucide-react import block)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Campaigns list page layout + columns + Nav placement)
  </read_first>
  <action>
    Create `app/phishing/page.tsx` (`'use client'`) with `<PageHeader title="Phishing Campaigns" description="Automatically detected phishing and spam campaigns awaiting triage." />` inside `<main className="container mx-auto px-6 py-6">`. Hold `campaigns`/`totalCount`/`page`/`pageSize(50)`/`isLoading` state; `fetchCampaigns(page)` calls `/api/phishing/campaigns?limit=50&offset={(page-1)*50}` and sets `items`/`total`. Render `<DataTable>` with columns per the UI-SPEC table: Campaign (`campaignKey` fallback "Campaign {id.slice(0,8)}", `font-mono text-sm`), Status (`StatusBadge` per the color table: open/proposed slate, false_positive slate, etc.), Reports (`reportCount`, right-aligned), First seen (`firstSeenAt`, relative + absolute title, `font-mono text-xs`), Last seen (`lastSeenAt`, same). `onRowClick` uses `useRouter().push('/phishing/tickets/' + campaign.firstReportTicketId)`. When `items.length === 0` render `<EmptyState icon={ShieldAlert} title="No campaigns yet" description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." />` in place of the table. Then MODIFY `components/navigation/app-navigation.tsx`: add `ShieldAlert` to the lucide-react import block and insert a new `navigationItems` entry immediately after the PAX8 item and before "Backup Status": `{ title: 'Phishing', href: '/phishing', icon: ShieldAlert, description: 'Phishing/spam campaign triage, evidence, and remediation approval' }`. Do NOT add it to any super-admin/role-gated filter — it is visible to all roles (every role has phishing:read).
  </action>
  <verify>
    <automated>grep -q "firstReportTicketId" app/phishing/page.tsx && grep -q "title: 'Phishing'" components/navigation/app-navigation.tsx && grep -q "ShieldAlert" components/navigation/app-navigation.tsx && npx tsc --noEmit --pretty 2>&1 | grep -Ec "app/phishing/page|app-navigation" | grep -qx 0 && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "firstReportTicketId" app/phishing/page.tsx` succeeds (row-click navigates via ticket id, not a campaign-scoped page)
    - `grep -q "/api/phishing/campaigns" app/phishing/page.tsx` succeeds (reuses the existing list endpoint)
    - `grep -q "title: 'Phishing'" components/navigation/app-navigation.tsx` and `grep -q "href: '/phishing'" ...` succeed
    - `grep -q "EmptyState" app/phishing/page.tsx` succeeds (empty state present)
    - `npx tsc --noEmit --pretty` reports no error in either file
  </acceptance_criteria>
  <done>Campaigns list page browses recent campaigns and navigates to the ticket-scoped review page; Phishing nav entry present for all roles.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: LiveLink numeric-ticket-id manual verification (REVIEW-01 / 22-RESEARCH resolved Open Question)</name>
  <files>none (live verification only — no file changes unless the ticket_number fallback is needed, which edits lib/services/phishing-ticket-resolver.ts)</files>
  <what-built>The ticket-scoped review page `/phishing/tickets/{ticketId}` (Task 1) assumes the URL segment is the numeric Autotask ticket id (`tickets.id` / `reports.ticket_id`), parsed via `Number(ticket_id)` in the plan-22-02 resolver route — consistent with the already-shipped Phase 18 `/api/phishing/tickets/{ticket_id}/analyze` route. 22-RESEARCH's Open Question on this was RESOLVED as a documented decision, gated on this manual confirmation (no live Autotask LiveLink admin access was available during research/planning).</what-built>
  <action>
    This is a blocking human-verification checkpoint — no code changes unless the fallback is triggered. The executor pauses here; a human confirms a real Autotask LiveLink click resolves against the numeric-ticket-id assumption, per how-to-verify. Do not auto-approve: the numeric-vs-ticket_number question could not be confirmed against a live Autotask tenant during planning, so a real click is the only way to close it. If confirmation fails (the segment is the human-readable ticket number), apply the documented one-line `ticket_number` fallback in `lib/services/phishing-ticket-resolver.ts` and re-verify.
  </action>
  <how-to-verify>
    1. In Autotask, configure (or inspect an existing) LiveLink button targeting `${BETTER_AUTH_URL}/phishing/tickets/` followed by the ticket-id dynamic-content field — reuse the SAME dynamic field the Phase 18 `/analyze` LiveLink (if one exists) already uses.
    2. From a real Autotask ticket that has a grouped phishing campaign, click the LiveLink button.
    3. Confirm the browser lands on `/phishing/tickets/{numeric id}` and the page RESOLVES (renders the campaign/classification/empty-state) — it must NOT return a 400 "Invalid ticket_id" from the resolver route.
    4. If instead the URL contains the human-readable ticket number (e.g. `T20260101.0001`) and the page 400s: apply the documented one-line fallback in `lib/services/phishing-ticket-resolver.ts` — look up via `reports.ticket_number` (already a stored column) when the segment is non-numeric — then re-run steps 2-3.
  </how-to-verify>
  <verify>
    <human-check>A real Autotask LiveLink click on a ticket with a grouped campaign lands on `/phishing/tickets/{numeric id}` and the page resolves (renders campaign/classification/empty-state), not a 400 "Invalid ticket_id"; if it 400s on a ticket_number segment, the documented resolver fallback is applied and the click then resolves.</human-check>
  </verify>
  <acceptance_criteria>
    - A real LiveLink click resolves to a rendered review page (not a 400) — OR the ticket_number fallback in phishing-ticket-resolver.ts was applied and the click then resolves.
    - The dynamic-content field used in the LiveLink button is recorded (matches or diverges from Phase 18 /analyze's field).
    - If deferred, the owner responsible for confirming before operator exposure is recorded.
  </acceptance_criteria>
  <done>A real Autotask LiveLink click resolves to the review page under the numeric-ticket-id assumption (or the documented ticket_number fallback was applied and now resolves); human typed "verified" / "verified — fallback applied" / "deferred: {owner}".</done>
  <resume-signal>Type "verified" once a real LiveLink click resolves correctly (or "verified — fallback applied" if the ticket_number fallback was needed and now works). If no Autotask tenant/LiveLink button is available yet, type "deferred: {owner}" to record who will confirm before the button is exposed to operators — the numeric-id assumption ships as-is per the RESOLVED research decision until then.</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser (unauth) → page | Middleware redirects unauthenticated requests to /auth/sign-in; /phishing is not in publicRoutes |
| LiveLink-supplied ticket id → page | The numeric AT ticket id is untrusted URL input, validated server-side by the resolver route |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-22-16 | Spoofing / Elevation (unauthenticated LiveLink access) | both pages | mitigate | REVIEW-01 — existing Better Auth session only; /phishing absent from middleware publicRoutes so an unauth request redirects to sign-in; no token/query-param auth added |
| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the Classify CTA is likewise gated on `phishing:analyze`; the page adds no relaxed check and the server routes enforce independently |
| T-22-18 | Information Disclosure (rendering unsanitized evidence) | review page | mitigate | Evidence rendering is delegated to EvidenceCard (plan 03 — inert URLs, plain-text body); the page passes data through, never via dangerouslySetInnerHTML |
| T-22-19 | Denial of Service / broken-link (LiveLink id semantics mismatch) | resolver + review page | mitigate | Numeric-id assumption documented (RESOLVED research decision) and gated by the blocking manual-verification checkpoint; a one-line `ticket_number` fallback is pre-specified if confirmation fails |
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` clean
- `npm test` full suite green
- Manual click-through (per 22-VALIDATION Manual-Only): review page in all states (classified full-campaign, grouped-but-not-yet-classified with Classify CTA, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly
- Blocking manual-verification checkpoint (Task 3): a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the ticket_number fallback is applied) — REVIEW-01 / 22-RESEARCH resolved Open Question
</verification>

<success_criteria>
Both operator surfaces work end-to-end: the LiveLink review page (all states including the default grouped-but-unclassified Classify CTA, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry; the LiveLink ticket-id addressing is confirmed (or its documented fallback applied) via the blocking checkpoint.
</success_criteria>

<output>
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-SUMMARY.md` when done
</output>
