---
phase: 18-campaign-grouping-phishing-analysis-api
plan: 03
type: execute
wave: 2
depends_on: [18-01]
files_modified:
  - app/api/phishing/campaigns/route.ts
  - app/api/phishing/campaigns/[id]/route.ts
autonomous: true
requirements: [CAMP-03, ACCESS-01]

must_haves:
  truths:
    - "GET /api/phishing/campaigns returns a paginated camelCase list { items, total, limit, offset } with per-campaign fields (id, campaignKey, groupMethod, firstSeenAt, lastSeenAt, reportCount, status, createdAt)"
    - "GET /api/phishing/campaigns/{id} returns one campaign with nested linked reports, messages, indicators, and classification history in camelCase"
    - "The detail route derives per-report requester email by joining reports.requester_contact_id -> contacts (campaigns has no recipients column)"
    - "Both routes call requirePermission('phishing','read') and reject unauthenticated/unauthorized requests with 401/403"
    - "The detail route validates the id is a UUID and returns 400 for a malformed id (not a 500), 404 when the campaign is absent"
  artifacts:
    - path: "app/api/phishing/campaigns/route.ts"
      provides: "GET campaign list (CAMP-03)"
      exports: ["GET"]
    - path: "app/api/phishing/campaigns/[id]/route.ts"
      provides: "GET campaign detail with nested children (CAMP-03)"
      exports: ["GET"]
  key_links:
    - from: "app/api/phishing/campaigns/[id]/route.ts"
      to: "reports/messages/indicators/classifications tables"
      via: "bulk-fetch keyed by ID array (= ANY($1::uuid[])) + Map assembly (device-link-conflicts pattern)"
      pattern: "ANY\\(\\$1::uuid\\[\\]\\)"
    - from: "app/api/phishing/campaigns/route.ts"
      to: "requirePermission('phishing', 'read')"
      via: "call-and-early-return auth gate (D-06)"
      pattern: "requirePermission\\('phishing', ?'read'\\)"
---

<objective>
Provide the read surface for campaigns: a paginated list and a full nested detail view (CAMP-03), both gated by `requirePermission('phishing','read')` (ACCESS-01). These are read-only routes over existing tables — no grouping logic, no writes.

Purpose: an operator browses campaigns and drills into one campaign's linked reports/messages/indicators/classification history. Depends on Plan 01 only for the `phishing` permission resource (`requirePermission('phishing','read')` must type-check).
Output: two new GET route files.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-campaign-grouping-phishing-analysis-api/18-CONTEXT.md
@.planning/phases/18-campaign-grouping-phishing-analysis-api/18-RESEARCH.md
@.planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md
@.planning/phases/18-campaign-grouping-phishing-analysis-api/18-01-SUMMARY.md

<interfaces>
<!-- Contracts the executor needs. From codebase + Plan 01 output. -->

From lib/auth-utils.ts (existing):
  export async function requirePermission(resource, action): Promise<{ session?, error? }>
  // Usage: const { error } = await requirePermission('phishing','read'); if (error) return error;
  // 'phishing' resource + read grant added in Plan 01 (lib/permissions.ts).

Route conventions (Next.js 16):
  - route files import: import postgresClient from '@/lib/services/postgres-client';  (default+absolute alias)
  - dynamic params are a Promise: { params }: { params: Promise<{ id: string }> }; const { id } = await params;
  - API responses are camelCase — handlers transform snake_case rows manually (CLAUDE.md)

Schema (migrations/097 + 099):
  campaigns(id UUID, campaign_key TEXT, group_method TEXT, first_seen_at, last_seen_at, report_count INT, status TEXT, created_at, updated_at)
  reports(id UUID, ticket_id BIGINT, ticket_number, company_name, title, requester_contact_id BIGINT, campaign_id UUID FK, created_at)
  messages(id UUID, report_id UUID, message_id TEXT, headers JSONB, urls JSONB, attachments JSONB, body_preview, created_at)  -- NO subject column; subject is headers->>'subject'
  indicators(id UUID, message_id UUID FK->messages(id), indicator_type TEXT, value TEXT, metadata JSONB [added migration 099], created_at)
  classifications(id UUID, campaign_id UUID, verdict TEXT, confidence NUMERIC, summary TEXT, reasons JSONB, recommended_actions JSONB, requires_approval BOOL, created_at)  -- Phase 19 stub; expect empty this phase, but include in response shape
  contacts(id, email_address VARCHAR, first_name, last_name)
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: GET /api/phishing/campaigns paginated list</name>
  <files>app/api/phishing/campaigns/route.ts</files>
  <read_first>
    - app/api/admin/device-link-conflicts/route.ts (the recommended analog: requirePermission -> clamp limit/offset -> optional WHERE with parameterized placeholder -> COUNT(*) -> manual camelCase .map())
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md section 4 (exact list route shape + the V5 note that the status filter value is never string-interpolated)
  </read_first>
  <action>
    Create `app/api/phishing/campaigns/route.ts` exporting `GET(request: NextRequest)`. First line: `const { error } = await requirePermission('phishing', 'read'); if (error) return error;` (D-06). Parse and clamp query params: `limit = Math.min(parseInt(searchParams.get('limit') ?? '50',10) || 50, 200)`, `offset = Math.max(parseInt(searchParams.get('offset') ?? '0',10) || 0, 0)`, optional `status`. Build a `params: unknown[] = [limit, offset]` array; if `status` present, push it and set `statusFilter = 'WHERE status = $' + params.length` (parameterized — never string-interpolate the value; V5/T-18-06). Query `SELECT id::text, campaign_key, group_method, first_seen_at::text, last_seen_at::text, report_count, status, created_at::text FROM campaigns ${statusFilter} ORDER BY last_seen_at DESC NULLS LAST LIMIT $1 OFFSET $2`. Run a separate `SELECT COUNT(*)::text AS count FROM campaigns ${statusFilter}` with `status ? [status] : []`. Return `NextResponse.json({ items, total, limit, offset })` where items are manually mapped to camelCase (id, campaignKey, groupMethod, firstSeenAt, lastSeenAt, reportCount, status, createdAt). Use `import postgresClient from '@/lib/services/postgres-client'`. Wrap DB work in try/catch returning 500 `{ error, message }` on failure (match device-link-conflicts error handling).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
    <human-check>Dev server on 3100. `curl http://localhost:3100/api/phishing/campaigns` with admin cookie returns 200 `{ items, total, limit, offset }` in camelCase; no cookie returns 401. `curl '.../campaigns?status=open&limit=5'` respects the filter and clamp.</human-check>
  </verify>
  <done>List route compiles; gated by requirePermission('phishing','read'); returns paginated camelCase { items, total, limit, offset }; status filter parameterized; limit clamped to 200.</done>
  <acceptance_criteria>
    - File exists at app/api/phishing/campaigns/route.ts and exports `GET`
    - Contains `requirePermission('phishing', 'read')` with `if (error) return error;`
    - status filter uses a `$${params.length}` placeholder — no `${status}` string interpolation into SQL text
    - Response objects use camelCase keys (campaignKey, groupMethod, firstSeenAt, lastSeenAt, reportCount, createdAt)
    - limit is clamped with Math.min(..., 200)
    - `npx tsc --noEmit --pretty` exits 0
    - Manual: 200 with admin cookie, 401 without (recorded in SUMMARY)
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 2: GET /api/phishing/campaigns/[id] nested detail</name>
  <files>app/api/phishing/campaigns/[id]/route.ts</files>
  <read_first>
    - app/api/admin/device-link-conflicts/route.ts (the exact bulk-fetch + Map + manual-camelCase nested-detail pattern to replicate — Pattern 3)
    - app/api/tickets/[id]/route.ts (params: Promise<{ id: string }> convention + not-found 404 early return)
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md section 5 (exact detail route shape, UUID validation, Pitfall 3 recipients-via-join, the messages.subject-is-in-headers-JSONB note)
  </read_first>
  <action>
    Create `app/api/phishing/campaigns/[id]/route.ts` exporting `GET(request: NextRequest, { params }: { params: Promise<{ id: string }> })`. First line: `const { error } = await requirePermission('phishing', 'read'); if (error) return error;` (D-06). Await params; validate `id` against `/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i` and return 400 `{ error: 'Invalid campaign id' }` if it fails (V5/T-18-05 — a malformed UUID would otherwise surface as a Postgres error -> uncaught 500). In try/catch: (1) parent `SELECT ... FROM campaigns WHERE id = $1`; return 404 `{ error: 'Campaign not found' }` if absent. (2) bulk-fetch linked reports: `SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_name, r.title, r.created_at::text, c.email_address AS requester_email FROM reports r LEFT JOIN contacts c ON c.id = r.requester_contact_id WHERE r.campaign_id = $1 ORDER BY r.created_at ASC` (Pitfall 3 — recipients derived via this contacts join; Pitfall 5 — join on requester_contact_id). (3) bulk-fetch messages keyed by the report-id array: `... WHERE report_id = ANY($1::uuid[])` (select `headers->>'subject' AS subject` since messages has no subject column). (4) bulk-fetch indicators keyed by the message-id array: `SELECT id::text, message_id::text, indicator_type, value, metadata FROM indicators WHERE message_id = ANY($1::uuid[])`. (5) classifications: `... WHERE campaign_id = $1 ORDER BY created_at DESC` (Phase 19 stub — likely empty, still include in the shape per CAMP-03). Guard each child query with an empty-array check (skip the query when the parent id array is empty). Assemble one camelCase JSON object: campaign fields + `reports[]` (id, ticketId, ticketNumber, companyName, title, createdAt, requesterEmail) + `messages[]` (id, reportId, messageId, subject) + `indicators[]` (id, messageId, indicatorType, value, metadata) + `classifications[]` (id, verdict, confidence, summary, createdAt). Use `import postgresClient from '@/lib/services/postgres-client'`. Catch -> `console.error` + 500 `{ error, message }`.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
    <human-check>Dev server on 3100. `curl http://localhost:3100/api/phishing/campaigns/{realCampaignUuid}` with admin cookie returns 200 with nested camelCase reports/messages/indicators/classifications; a malformed id (e.g. `/campaigns/abc`) returns 400; a well-formed-but-absent UUID returns 404; no cookie returns 401.</human-check>
  </verify>
  <done>Detail route compiles; gated by requirePermission('phishing','read'); UUID-validated (400) with 404 for absent campaign; returns nested camelCase reports/messages/indicators/classifications assembled via bulk-fetch + Map.</done>
  <acceptance_criteria>
    - File exists at app/api/phishing/campaigns/[id]/route.ts and exports `GET`
    - Contains `requirePermission('phishing', 'read')` with `if (error) return error;`
    - Contains a UUID regex validation returning 400 before any query, and a 404 for a missing campaign
    - Uses `= ANY($1::uuid[])` bulk-fetch for at least messages and indicators (grep confirms)
    - reports query LEFT JOINs contacts on `r.requester_contact_id` and selects `email_address AS requester_email`
    - messages subject is pulled via `headers->>'subject'` (messages has no subject column)
    - Response nests reports/messages/indicators/classifications in camelCase
    - `npx tsc --noEmit --pretty` exits 0
    - Manual: 200 (admin) / 400 (malformed id) / 404 (absent) / 401 (no cookie) recorded in SUMMARY
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → /api/phishing/campaigns[/{id}] | authenticated staff request; must carry phishing:read permission |
| status query param + id path param → SQL | untrusted input crosses into DB queries |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-01 | Elevation of Privilege | both GET routes | mitigate | `requirePermission('phishing', 'read')` first-line gate returns 401/403; user-role has read (D-05) so browsing works while the fine-grained convention is established |
| T-18-06 | Tampering | list route status filter | mitigate | status value bound via a `$n` placeholder (device-link-conflicts pattern) — never string-interpolated into SQL text |
| T-18-05 | Information Disclosure | detail route id path param | mitigate | UUID regex validation returns 400 before querying — a malformed UUID cannot surface as a leaked 500 Postgres error |
| T-18-02 | Information Disclosure (IDOR) | detail by arbitrary campaign UUID | accept | single-tenant internal MSP tool; consistent with existing route conventions — no per-tenant scoping introduced |

No package installs this phase — supply-chain (T-{phase}-SC) row not applicable.
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` clean for both route files
- `grep` confirms `ANY($1::uuid[])` bulk-fetch in the detail route and a parameterized status filter in the list route
- Manual (pre-verify gate, CAMP-03/ACCESS-01): curl list + detail with admin cookie (camelCase nested shape), 401 without cookie, 400 on malformed detail id, 404 on absent campaign
</verification>

<success_criteria>
- GET /api/phishing/campaigns lists campaigns (paginated camelCase) and GET /api/phishing/campaigns/{id} returns full nested detail (linked reports, messages, indicators, classification history) (CAMP-03)
- Both routes enforce requirePermission('phishing','read') (ACCESS-01)
- Malformed/absent ids handled with 400/404 (no 500 leak)
</success_criteria>

<output>
Create `.planning/phases/18-campaign-grouping-phishing-analysis-api/18-03-SUMMARY.md` when done
</output>
