---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
plan: 02
type: execute
wave: 2
depends_on: ["22-01"]
files_modified:
  - app/api/phishing/tickets/[ticket_id]/campaign/route.ts
  - app/api/phishing/campaigns/[id]/route.ts
  - app/api/phishing/campaigns/route.ts
autonomous: true
requirements: [REVIEW-01, REVIEW-02, REVIEW-03, REVIEW-04]
must_haves:
  truths:
    - "GET /api/phishing/tickets/{ticketId}/campaign returns { found:false } (200) when no report exists, { found:true, campaignId:null } for an ungrouped report, and { found:true, campaignId } for a grouped report"
    - "GET /api/phishing/campaigns/{id} additionally returns remediationActions (with derived completedAt), auditEvents, full classification fields (reasons/recommendedActions/requiresApproval), full message evidence (headers/urls/attachments/bodyPreview), a fresh blastRadius, and a merged chronological timeline"
    - "GET /api/phishing/campaigns additionally returns firstReportTicketId per campaign"
    - "All three routes reject an unauthenticated/unauthorized caller via requirePermission('phishing','read')"
  artifacts:
    - path: "app/api/phishing/tickets/[ticket_id]/campaign/route.ts"
      provides: "ticket→campaign resolver route"
      exports: ["GET"]
    - path: "app/api/phishing/campaigns/[id]/route.ts"
      provides: "extended campaign-detail response for the review page"
      contains: "blastRadius"
    - path: "app/api/phishing/campaigns/route.ts"
      provides: "campaigns list with firstReportTicketId for row navigation"
      contains: "firstReportTicketId"
  key_links:
    - from: "app/api/phishing/tickets/[ticket_id]/campaign/route.ts"
      to: "lib/services/phishing-ticket-resolver.ts"
      via: "resolveTicketToCampaign(ticketId)"
      pattern: "resolveTicketToCampaign"
    - from: "app/api/phishing/campaigns/[id]/route.ts"
      to: "lib/services/mimecast-blast-radius.ts"
      via: "getBlastRadius fresh per request"
      pattern: "getBlastRadius"
    - from: "app/api/phishing/campaigns/[id]/route.ts"
      to: "lib/services/phishing-timeline.ts"
      via: "mergeTimeline(reports, classifications, auditEvents)"
      pattern: "mergeTimeline"
---

<objective>
Provide the review page's read surface. Add one new thin resolver route (ticket ID → campaign) and additively extend the two existing campaign endpoints so the page has everything REVIEW-02/03/04 need — without breaking any existing consumer (grep confirms the detail route has no other consumer besides itself).

Purpose: The Phase 20 write routes (approve/remediate/mark-false-positive) already exist and are reused verbatim; the only missing backend piece is the enriched read data. This plan supplies it.
Output: 1 new route + 2 additively-extended routes.
</objective>

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

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

<interfaces>
From lib/services/phishing-ticket-resolver.ts (plan 01):
  export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution>
  export interface TicketCampaignResolution { found: boolean; reportId?: string; campaignId?: string | null; ticketNumber?: string | null; }

From lib/services/phishing-timeline.ts (plan 01):
  export function mergeTimeline(reports, classifications, auditEvents): TimelineEntry[]

From lib/services/mimecast-blast-radius.ts (existing):
  export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult>
  BlastRadiusInput { messageId?: string; sender: string; recipient: string; subject: string; dateWindow: { start: Date; end: Date }; }
  BlastRadiusResult = { status:'unavailable'; reason:'not_configured'|'lookup_failed'; error? } | { status:'ok'; matched; delivered; held; rejected; clicked; perRecipient: {recipient; status}[] }

From lib/services/remediation-service.ts (existing, for reference — NOT modified here):
  ApproveActionInput { actionType: string; params?: Record<string, unknown> }

Existing detail route messages query TODAY only selects id/report_id/message_id/subject — this plan widens it to include headers, urls, attachments, body_preview.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: NEW ticket→campaign resolver route</name>
  <files>app/api/phishing/tickets/[ticket_id]/campaign/route.ts</files>
  <read_first>
    - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact requirePermission + Number(ticket_id) validation + try/catch idiom to mirror)
    - lib/services/phishing-ticket-resolver.ts (plan 01 — the function this route wraps)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section for this route)
  </read_first>
  <action>
    Create `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` exporting `GET(request, { params }: { params: Promise<{ ticket_id: string }> })`. First `const { error } = await requirePermission('phishing', 'read'); if (error) return error;`. Then `const { ticket_id } = await params; const ticketId = Number(ticket_id); if (!Number.isFinite(ticketId)) return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 });`. Wrap in try/catch (copy the `console.error('[PHISHING-TICKET-CAMPAIGN] ...', ticketId, err)` + 500 shape from analyze/route.ts). In the try, call `resolveTicketToCampaign(ticketId)` and return it directly via `NextResponse.json(...)`. Note deliberate design (D-07): a missing report is `{ found: false }` at HTTP 200, NOT a 404 — the page distinguishes "valid ticket, not triaged yet" from a hard error via the `found` boolean. Use GET (pure read), not POST. Import postgresClient is NOT needed here (the service owns the query).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty 2>&1 | grep -c "phishing/tickets/\[ticket_id\]/campaign" | grep -qx 0 && echo TYPECHECK-CLEAN</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "requirePermission('phishing', 'read')" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds
    - `grep -q "resolveTicketToCampaign" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds
    - `grep -q "Number.isFinite" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds
    - `grep -q "export async function GET" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds and no POST/PUT/PATCH/DELETE export exists
    - `npx tsc --noEmit --pretty` reports no error in this file
  </acceptance_criteria>
  <done>Resolver route returns found/reportId/campaignId/ticketNumber, auth-gated, param-validated.</done>
</task>

<task type="auto">
  <name>Task 2: EXTEND campaigns/[id] detail route (evidence + timeline + classification + blast radius)</name>
  <files>app/api/phishing/campaigns/[id]/route.ts</files>
  <read_first>
    - app/api/phishing/campaigns/[id]/route.ts (the full existing file — extend, do not rewrite; keep every existing field/query untouched)
    - lib/services/campaign-classifier.ts lines 320-360 (gatherCampaignEvidence — the CORRECT sender/recipient/subject/dateWindow derivation to copy for getBlastRadius; do NOT copy triage-note-service.ts's empty-string call — Pitfall 3)
    - lib/services/phishing-eml-service.ts (the exact messages.headers/urls/attachments/body_preview persisted JSONB shapes)
    - lib/services/phishing-audit.ts (audit_events columns: actor, event_type, payload; the remediation_completed payload.actionId used to derive completedAt)
    - lib/services/phishing-timeline.ts (plan 01 — mergeTimeline)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "app/api/phishing/campaigns/[id]/route.ts (EXTEND)")
  </read_first>
  <action>
    Additively extend the existing GET handler. (1) Widen the existing `messagesRes` SELECT to also return `headers, urls, attachments, body_preview` (all real columns on `messages`; subject already comes from `headers->>'subject'`) and include them (camelCased: headers/urls/attachments/bodyPreview) in the existing `messages` map output. (2) Add a `remediationRes` query `SELECT id::text, action_type, status, params, approved_by, approved_at::text FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`. (3) Add an `auditRes` query `SELECT id::text, actor, event_type, payload, created_at::text FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`. (4) Widen the existing `classificationsRes` SELECT to add `reasons, recommended_actions, requires_approval` (real columns; keep `ORDER BY created_at DESC`) and include them (camelCased reasons/recommendedActions/requiresApproval) in the map. (5) Build `completedAtByActionId` in app code by iterating auditRes rows where `event_type === 'remediation_completed'` and reading `(payload as {actionId?:string}).actionId → created_at`; map `remediationActions` to camelCase including `completedAt: completedAtByActionId.get(a.id) ?? null` (there is NO completed_at column — Pitfall 2). (6) Derive `blastRadius` by copying campaign-classifier.ts's gatherCampaignEvidence logic exactly: take the earliest report (reports[0]), find its message, find the `sender`-type indicator, call `getBlastRadius({ sender: senderIndicator?.value ?? primaryMessage?.headers.from.email ?? '', recipient: primaryReport.requesterEmail ?? '', subject: primaryMessage?.subject ?? primaryReport.title ?? '', dateWindow: { start: createdAt - 24h, end: createdAt + 24h } })`; when there is no report use `{ status: 'unavailable', reason: 'not_configured' }`. Await it inside the existing try block. (7) Add `timeline: mergeTimeline(reports, classifications, auditEvents)` to the response using the already-mapped camelCase arrays. Add the four new response fields (`remediationActions`, `auditEvents`, `blastRadius`, `timeline`) and the widened `messages`/`classifications` shapes to the returned JSON. Do NOT alter the existing catch block, the UUID_RE guard, or any existing field name. Add imports for `getBlastRadius`/`BlastRadiusResult` from `@/lib/services/mimecast-blast-radius` and `mergeTimeline` from `@/lib/services/phishing-timeline`.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty 2>&1 | grep -c "campaigns/\[id\]/route" | grep -qx 0 && echo TYPECHECK-CLEAN</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "getBlastRadius" app/api/phishing/campaigns/[id]/route.ts` succeeds
    - `grep -q "mergeTimeline" app/api/phishing/campaigns/[id]/route.ts` succeeds
    - `grep -q "FROM remediation_actions WHERE campaign_id" app/api/phishing/campaigns/[id]/route.ts` succeeds
    - `grep -q "FROM audit_events WHERE campaign_id" app/api/phishing/campaigns/[id]/route.ts` succeeds
    - `grep -q "recommended_actions" app/api/phishing/campaigns/[id]/route.ts` and `grep -q "requires_approval" ...` both succeed
    - `grep -v '^#' app/api/phishing/campaigns/[id]/route.ts | grep -c "completed_at"` returns 0 (no reference to the nonexistent column)
    - The existing `requirePermission('phishing', 'read')` call and `UUID_RE` guard remain present
    - `npx tsc --noEmit --pretty` reports no error in this file
  </acceptance_criteria>
  <done>Detail route returns enriched evidence, timeline, classification, remediation actions with derived completedAt, and fresh blast radius — all additive.</done>
</task>

<task type="auto">
  <name>Task 3: EXTEND campaigns list route with firstReportTicketId</name>
  <files>app/api/phishing/campaigns/route.ts</files>
  <read_first>
    - app/api/phishing/campaigns/route.ts (full existing file — additive change only)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Campaigns list page section — firstReportTicketId is the row-click navigation target)
  </read_first>
  <action>
    Add one correlated subquery column to the existing campaigns SELECT: `(SELECT r.ticket_id::text FROM reports r WHERE r.campaign_id = c.id ORDER BY r.created_at ASC LIMIT 1) AS first_report_ticket_id`. Alias the campaigns table as `c` in that SELECT (currently unaliased) — apply the alias consistently to the existing selected columns and to the `${statusFilter}`/ORDER BY so nothing breaks. Add `firstReportTicketId: c.first_report_ticket_id` to the existing `items.map()`. Do NOT change the count query, the limit/offset logic, the requirePermission gate, or the response envelope shape (`{ items, total, limit, offset }`). Purely additive field.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty 2>&1 | grep -c "campaigns/route" | grep -qx 0 && echo TYPECHECK-CLEAN</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "first_report_ticket_id" app/api/phishing/campaigns/route.ts` succeeds
    - `grep -q "firstReportTicketId" app/api/phishing/campaigns/route.ts` succeeds
    - The response still returns `{ items, total, limit, offset }` (envelope unchanged)
    - `npx tsc --noEmit --pretty` reports no error in this file
  </acceptance_criteria>
  <done>List route returns firstReportTicketId per campaign for row-click navigation.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser→API | Operator supplies ticket_id (numeric) and campaign id (UUID) in the URL path — untrusted |
| API→DB | Parameterized queries only |
| API→Mimecast (server-side) | getBlastRadius runs server-side with server-only Mimecast creds; never called from the client |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-22-03 | Elevation of Privilege | all three routes | mitigate | Each route calls `requirePermission('phishing', 'read')` and returns its error before any query — identical to the 6 existing phishing routes |
| T-22-04 | Tampering (SQLi) | resolver + extended routes | mitigate | ticket_id parsed via `Number.isFinite`; campaign id gated by existing `UUID_RE`; all queries parameterized |
| T-22-05 | Information Disclosure (campaign-UUID enumeration) | campaigns/[id] | mitigate | requirePermission('phishing','read') gates the whole route before the DB query; a guessed UUID without read permission 401/403s first |
| T-22-06 | Information Disclosure (blast radius scoped to wrong sender/recipient) | campaigns/[id] blastRadius | mitigate | Copy campaign-classifier's real sender/recipient derivation, not triage-note-service's empty-string call (Pitfall 3) — prevents an unscoped Mimecast fan-out |
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH Package Legitimacy Audit — zero new packages) |
</threat_model>

<verification>
- `npm test` green (plan 01 tests still pass; no test coverage for routes under vitest config — type-check is the safety net per CLAUDE.md)
- `npx tsc --noEmit --pretty` clean
- Manual: `curl` the resolver route for a known-triaged ticket returns `found:true` with a campaignId
</verification>

<success_criteria>
Review page can fetch (a) a ticket→campaign resolution and (b) an enriched campaign detail containing evidence, timeline, classification, remediation actions, and blast radius; list page can fetch campaigns with firstReportTicketId. All auth-gated, all additive.
</success_criteria>

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