---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/services/phishing-ticket-resolver.ts
  - lib/services/phishing-ticket-resolver.test.ts
  - lib/services/remediation-default-params.ts
  - lib/services/remediation-default-params.test.ts
  - lib/services/phishing-timeline.ts
  - lib/services/phishing-timeline.test.ts
autonomous: true
requirements: [REVIEW-01, REVIEW-02, REVIEW-04]
must_haves:
  truths:
    - "Given a ticket id with no reports row, the resolver returns { found: false }"
    - "Given a ticket id whose report has a null campaign_id, the resolver returns { found: true, campaignId: null }"
    - "Given a ticket id whose report is grouped, the resolver returns { found: true, reportId, campaignId, ticketNumber }"
    - "deriveDefaultParams returns the exact param object for each of the 7 action types"
    - "mergeTimeline returns a single array of report/classification/audit entries sorted ascending by timestamp"
  artifacts:
    - path: "lib/services/phishing-ticket-resolver.ts"
      provides: "resolveTicketToCampaign(ticketId) DB lookup"
      exports: ["resolveTicketToCampaign", "TicketCampaignResolution"]
    - path: "lib/services/remediation-default-params.ts"
      provides: "7-action-type default-param derivation"
      exports: ["deriveDefaultParams", "DefaultParamEvidence"]
    - path: "lib/services/phishing-timeline.ts"
      provides: "chronological merge of reports + classifications + audit events"
      exports: ["mergeTimeline", "TimelineEntry"]
  key_links:
    - from: "lib/services/phishing-ticket-resolver.ts"
      to: "reports table"
      via: "postgresClient.query SELECT ... FROM reports WHERE ticket_id = $1"
      pattern: "FROM reports WHERE ticket_id"
---

<objective>
Extract the three pieces of genuinely new pure logic in this phase into testable `lib/services/*.ts` files so they can be unit-tested under vitest (whose `test.include` is `lib/**/*.test.ts` only — `app/**` and `components/**` have no coverage). These are the phase's Wave 0 foundation: the ticket→campaign resolver (REVIEW-01), the 7-action-type default-param derivation (REVIEW-04), and the timeline merge/sort (REVIEW-02, server-merge approach chosen per RESEARCH.md Alternatives Considered).

Purpose: Downstream route (plan 02) and component (plans 04/05) work consumes these; extracting them makes the phase's only new business logic verifiable rather than trapped inline in untestable route/component files.
Output: 3 service modules + 3 vitest test files, all green.
</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
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md

<interfaces>
From lib/services/postgres-client.ts:
  default export `postgresClient` (also named export). Use `postgresClient.query<Row>(sql, params)`.

From lib/services/remediation-service.ts (the shape approve params must match):
  export interface ApproveActionInput { actionType: string; params?: Record<string, unknown>; }

Confirmed 7-action vocabulary (lib/services/campaign-classifier.ts mapVerdictToActions):
  no_action | warn_user | block_sender | purge_message | reset_password | isolate_endpoint | disable_forwarding_rule
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: phishing-ticket-resolver.ts + test</name>
  <files>lib/services/phishing-ticket-resolver.ts, lib/services/phishing-ticket-resolver.test.ts</files>
  <read_first>
    - lib/services/campaign-classifier.ts (top-level exported-async-function + typed-row + postgresClient idiom, and an existing vitest mock of postgres-client)
    - lib/services/campaign-classifier.test.ts (how postgres-client is mocked with vi.mock)
    - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (the Number(ticket_id) param semantics — ticket_id is the numeric tickets.id / reports.ticket_id)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "lib/services/phishing-ticket-resolver.ts")
  </read_first>
  <behavior>
    - Given reports has no row for ticketId: resolveTicketToCampaign(ticketId) resolves to { found: false }
    - Given a reports row with campaign_id = null: resolves to { found: true, reportId, campaignId: null, ticketNumber }
    - Given a reports row with a campaign_id UUID: resolves to { found: true, reportId, campaignId: <uuid>, ticketNumber }
  </behavior>
  <action>
    Create `lib/services/phishing-ticket-resolver.ts` exporting `interface TicketCampaignResolution { found: boolean; reportId?: string; campaignId?: string | null; ticketNumber?: string | null; }` and `export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution>`. It runs `postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>('SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1', [ticketId])`, reads `res.rows[0]`; if absent returns `{ found: false }`; otherwise returns `{ found: true, reportId: row.id, campaignId: row.campaign_id, ticketNumber: row.ticket_number }`. Import postgresClient matching the sibling-file convention (default import from `./postgres-client`). Do NOT put requirePermission or param parsing here — that lives in the route (plan 02). Write `phishing-ticket-resolver.test.ts` that mocks `./postgres-client` via `vi.mock` (mirror campaign-classifier.test.ts) and asserts the three behaviors above by controlling the mocked query's returned rows.
  </action>
  <verify>
    <automated>npx vitest run lib/services/phishing-ticket-resolver.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/phishing-ticket-resolver.test.ts` passes with at least 3 assertions (no-report, ungrouped, grouped)
    - `grep -q "FROM reports WHERE ticket_id = \$1" lib/services/phishing-ticket-resolver.ts` succeeds
    - The file contains no `requirePermission` and no `NextResponse` import (pure service, route-free)
  </acceptance_criteria>
  <done>resolveTicketToCampaign exported, test green, three resolution states covered.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: remediation-default-params.ts + test</name>
  <files>lib/services/remediation-default-params.ts, lib/services/remediation-default-params.test.ts</files>
  <read_first>
    - lib/services/campaign-classifier.ts (mapVerdictToActions — the exhaustive-switch shape to mirror)
    - lib/services/remediation-service.ts lines 42-45 (ApproveActionInput shape the params feed into)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (section "Action Area Spec" → the 7-row default-params table)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "lib/services/remediation-default-params.ts")
  </read_first>
  <behavior>
    - deriveDefaultParams('no_action', ev) === {}
    - deriveDefaultParams('warn_user', ev) === { recipientEmail: ev.requesterEmail ?? '', message: '' }
    - deriveDefaultParams('block_sender', ev) === { senderEmail: ev.senderEmail ?? '', senderDomain: ev.senderDomain ?? '' }
    - deriveDefaultParams('purge_message', ev) === { messageId: ev.messageId ?? '', mailboxes: [] }
    - deriveDefaultParams('reset_password', ev) === { userPrincipalName: ev.requesterEmail ?? '' }
    - deriveDefaultParams('isolate_endpoint', ev) === { deviceId: '' }
    - deriveDefaultParams('disable_forwarding_rule', ev) === { userPrincipalName: ev.requesterEmail ?? '', ruleName: '' }
    - deriveDefaultParams('unknown_future_type', ev) === {}
  </behavior>
  <action>
    Create `lib/services/remediation-default-params.ts` exporting `interface DefaultParamEvidence { requesterEmail: string | null; senderEmail: string | null; senderDomain: string | null; messageId: string | null; }` and `export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record<string, unknown>` implemented as a switch over the 7 action types exactly per the UI-SPEC Action Area default-params table (values listed in behavior above), with a `default: return {}` for any unknown/future action type. Pure function, no imports beyond types. Write `remediation-default-params.test.ts` asserting all 8 behaviors above (7 known types + 1 unknown fallback), using an evidence fixture with non-null values to prove pass-through and a second with all-null values to prove the `?? ''` / `?? ''` fallbacks.
  </action>
  <verify>
    <automated>npx vitest run lib/services/remediation-default-params.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/remediation-default-params.test.ts` passes with assertions for all 7 known action types plus the unknown-type fallback
    - `grep -c "case '" lib/services/remediation-default-params.ts` returns at least 7
    - deriveDefaultParams is a pure function (no postgresClient / fetch / NextResponse import)
  </acceptance_criteria>
  <done>All 7 action-type default-param shapes + unknown fallback verified by test.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: phishing-timeline.ts + test (server-merge)</name>
  <files>lib/services/phishing-timeline.ts, lib/services/phishing-timeline.test.ts</files>
  <read_first>
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (section "Timeline Spec" — the three sources and ascending-by-created_at ordering)
    - app/api/phishing/campaigns/[id]/route.ts (the camelCase report/classification row shapes this merge consumes)
    - lib/services/phishing-audit.ts (the 4 canonical audit_events event_type values)
  </read_first>
  <behavior>
    - Given 2 reports, 1 classification, 2 audit events with interleaved timestamps, mergeTimeline returns a 5-element array sorted ascending by `at`
    - Each entry carries a discriminant `kind` of 'report' | 'classification' | 'audit' and the source fields (report: reportId/ticketNumber/companyName; classification: verdict/confidence; audit: eventType/actor/payload)
    - Ties on identical timestamps preserve a stable order (report, then classification, then audit) and do not throw
  </behavior>
  <action>
    Create `lib/services/phishing-timeline.ts` exporting a discriminated union `type TimelineEntry` with three variants: `{ kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }`, `{ kind: 'classification'; at: string; verdict: string; confidence: string | null }`, `{ kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }`. Export `function mergeTimeline(reports: {...}[], classifications: {...}[], auditEvents: {...}[]): TimelineEntry[]` that maps each source into its variant (using each source's created_at as `at`), concatenates, and sorts ascending by `at` (compare via `Date(a.at).getTime() - Date(b.at).getTime()`; on equal times fall back to a fixed kind priority report<classification<audit). Pure function, no DB/fetch imports. Write `phishing-timeline.test.ts` asserting the three behaviors above with an interleaved-timestamp fixture.
  </action>
  <verify>
    <automated>npx vitest run lib/services/phishing-timeline.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/phishing-timeline.test.ts` passes, including an assertion that output timestamps are non-decreasing
    - Output length equals sum of the three input array lengths
    - `grep -q "kind: 'audit'" lib/services/phishing-timeline.ts` succeeds (all three variants present)
    - mergeTimeline imports no postgresClient / NextResponse (pure)
  </acceptance_criteria>
  <done>mergeTimeline produces a single ascending chronological array across all three sources; test green.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| route→service (plan 02 calls these) | These services receive an already-parsed `ticketId: number` and already-fetched row arrays; they perform no input parsing themselves |
| service→DB | resolveTicketToCampaign issues a parameterized query only |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-22-01 | Tampering (SQL injection) | phishing-ticket-resolver.ts | mitigate | Parameterized query `WHERE ticket_id = $1` with a `number` param — no string interpolation into SQL |
| T-22-02 | Information Disclosure | resolveTicketToCampaign | accept | Returns only reportId/campaignId/ticketNumber; access control is enforced in the route layer (plan 02), not here — this pure service is never exposed directly |
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan; per 22-RESEARCH Package Legitimacy Audit the phase introduces zero new npm packages |
</threat_model>

<verification>
- `npm test` (full suite) green
- `npx tsc --noEmit --pretty` clean
</verification>

<success_criteria>
Three pure-logic services exist with passing unit tests covering resolver (3 states), default-params (7 types + fallback), and timeline merge (chronological, 3 sources).
</success_criteria>

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