---
phase: 18-campaign-grouping-phishing-analysis-api
plan: 02
type: execute
wave: 2
depends_on: [18-01]
files_modified:
  - app/api/phishing/tickets/[ticket_id]/analyze/route.ts
  - lib/services/webhook-service.ts
  - lib/services/phishing-sweep-service.ts
autonomous: true
requirements: [DETECT-03, CAMP-01, CAMP-02, ACCESS-01]

must_haves:
  truths:
    - "POST /api/phishing/tickets/{ticket_id}/analyze runs detect -> parse -> group for one ticket and returns { reportId, campaignId, groupMethod, created }"
    - "The analyze route rejects a request lacking the phishing:analyze permission with 401/403 (user-role gets read only per D-05)"
    - "The analyze route validates ticket_id is numeric and returns 400 for a non-numeric value; 404 when the ticket row is absent; 400 when the ticket does not match phishing patterns"
    - "The webhook ticket.created path calls groupReportIntoCampaign(reportId, {skipIfAlreadyGrouped:true}) after detectPhishingTicket flags a report"
    - "The cron sweep loop calls groupReportIntoCampaign(reportId, {skipIfAlreadyGrouped:true}) per flagged ticket, inside the same per-ticket try/catch"
  artifacts:
    - path: "app/api/phishing/tickets/[ticket_id]/analyze/route.ts"
      provides: "POST on-demand analyze endpoint (DETECT-03)"
      exports: ["POST"]
    - path: "lib/services/webhook-service.ts"
      provides: "grouping call wired into triggerPhishingDetection (D-01)"
      contains: "groupReportIntoCampaign"
    - path: "lib/services/phishing-sweep-service.ts"
      provides: "grouping call wired into the per-ticket sweep loop (D-01)"
      contains: "groupReportIntoCampaign"
  key_links:
    - from: "app/api/phishing/tickets/[ticket_id]/analyze/route.ts"
      to: "detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign"
      via: "orchestration chain (D-08: no skipIfAlreadyGrouped — always re-run)"
      pattern: "groupReportIntoCampaign\\(detection\\.reportId\\)"
    - from: "app/api/phishing/tickets/[ticket_id]/analyze/route.ts"
      to: "requirePermission('phishing', 'analyze')"
      via: "call-and-early-return auth gate (D-06)"
      pattern: "requirePermission\\('phishing', ?'analyze'\\)"
---

<objective>
Expose the on-demand analysis endpoint and wire the shared grouping function into the two existing automatic trigger points, so campaigns form/accumulate automatically (D-01) and an operator can force full analysis of one ticket on demand (DETECT-03).

Purpose: `POST /api/phishing/tickets/{ticket_id}/analyze` is the first `/api/phishing/*` write route and the intended first caller of Phase 16's `parseAndStoreMessage`. The webhook + cron wiring make grouping happen without an API call (D-01), using `skipIfAlreadyGrouped` to stay cheap (D-08).
Output: one new route file + two modified service 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/services/campaign-grouping-service.ts (Plan 01 output):
  export async function groupReportIntoCampaign(reportId: string, opts?: { skipIfAlreadyGrouped?: boolean }): Promise<GroupReportResult | null>
  export interface GroupReportResult { campaignId: string; groupMethod: 'message_id'|'attachment_or_url'|'sender_subject_client'; created: boolean; }

From lib/services/phishing-detector.ts (existing):
  export interface DetectableTicket { id: number; ticket_number: string|null; title: string|null; description: string|null; company_id: number|null; contact_id: number|null; created_by_contact_id: number|null; }
  export async function detectPhishingTicket(ticket: DetectableTicket): Promise<{ flagged: boolean; reportId?: string; skippedUnchanged?: boolean; ... }>

From lib/services/phishing-eml-service.ts (existing — currently uncalled outside its test; this route is its intended first caller per Phase 16):
  export async function parseAndStoreMessage(input: { reportId: string; ticketId: number }): Promise<{ stored: boolean; reason?: 'no-eml-attachment'|'no-attachment-content'|'oversized-attachment'; messageId?: string }>
  // NEVER throws for expected no-op cases (returns { stored:false, reason }); only real Autotask/DB failures throw.

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

Route conventions (Next.js 16):
  - dynamic params are a Promise: { params }: { params: Promise<{ ticket_id: string }> }; const { ticket_id } = await params;
  - route files import: import postgresClient from '@/lib/services/postgres-client';  (default+absolute alias — matches every other route file)
  - tickets table (source ticket row): id BIGINT, ticket_number, title, description, company_id, contact_id, created_by_contact_id
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: POST /api/phishing/tickets/[ticket_id]/analyze route</name>
  <files>app/api/phishing/tickets/[ticket_id]/analyze/route.ts</files>
  <read_first>
    - app/api/tickets/[id]/route.ts (Next.js 16 params: Promise<> convention + parseInt(id) + not-found 404 early return)
    - app/api/rmm/executions/route.ts (POST handler: requirePermission call-and-early-return + try/catch client-vs-server error status split)
    - lib/services/webhook-service.ts (its "read the ticket row back from Postgres" pattern — the analyze route has no Autotask entity payload, so it reconstructs a DetectableTicket from the tickets table the same way)
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md section 3 (exact route shape + the note on parseAndStoreMessage stored:false NOT being an error)
  </read_first>
  <action>
    Create `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` exporting `POST(request: NextRequest, { params }: { params: Promise<{ ticket_id: string }> })`. First line of the handler: `const { error } = await requirePermission('phishing', 'analyze'); if (error) return error;` (D-06). Await params, `const ticketId = Number(ticket_id)`; return `NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 })` when `!Number.isFinite(ticketId)` (V5 input validation). In a try/catch: SELECT `id, ticket_number, title, description, company_id, contact_id, created_by_contact_id FROM tickets WHERE id = $1` with `[ticketId]`; return 404 `{ error: 'Ticket not found' }` if no row. Build a `DetectableTicket` from the row (Number(id), pass the contact fields through). Call `detectPhishingTicket(ticket)`; if `!detection.flagged || !detection.reportId` return 400 `{ error: 'Ticket does not match known phishing patterns' }`. Call `await parseAndStoreMessage({ reportId: detection.reportId, ticketId })` — do NOT treat a `{ stored:false, reason }` result as an error (grouping still proceeds). Call `await groupReportIntoCampaign(detection.reportId)` WITHOUT `skipIfAlreadyGrouped` (D-08 — always re-run, allowing a Tier-3 grouping to upgrade to Tier-1 now that messages/indicators may exist). Return `NextResponse.json({ reportId: detection.reportId, campaignId: grouped?.campaignId ?? null, groupMethod: grouped?.groupMethod ?? null, created: grouped?.created ?? false })` (camelCase per CLAUDE.md). Catch: `console.error('[PHISHING-ANALYZE] ...', ticketId, err)` and return 500 `{ error, message: err instanceof Error ? err.message : 'Unknown error' }`. Use `import postgresClient from '@/lib/services/postgres-client'` (default+absolute alias).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
    <human-check>Start dev server (npm run dev, port 3100). (a) `curl -X POST http://localhost:3100/api/phishing/tickets/{knownPhishingTicketId}/analyze` with a valid admin session cookie returns 200 with reportId/campaignId/groupMethod/created. (b) Same route with no cookie returns 401. (c) Same route with a user-role session cookie returns 403 (D-05 grants user read only, not analyze). (d) `curl -X POST .../tickets/abc/analyze` returns 400.</human-check>
  </verify>
  <done>Route compiles; requirePermission('phishing','analyze') gates it; orchestrates detect->parse->group; returns camelCase linkage; ticket_id validated (400), missing ticket (404), non-phishing ticket (400) handled.</done>
  <acceptance_criteria>
    - File exists at app/api/phishing/tickets/[ticket_id]/analyze/route.ts and exports `POST`
    - Contains `requirePermission('phishing', 'analyze')` as the first handler statement with `if (error) return error;`
    - Contains `params: Promise<{ ticket_id: string }>` and `await params`
    - Calls detectPhishingTicket, parseAndStoreMessage, and groupReportIntoCampaign(detection.reportId) with NO skipIfAlreadyGrouped argument
    - Returns 400 for non-finite ticket_id, 404 for missing ticket, 400 for non-phishing ticket
    - `npx tsc --noEmit --pretty` exits 0
    - Manual: 401 without cookie, 403 with user-role cookie, 200 with admin cookie (recorded in SUMMARY)
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 2: Wire groupReportIntoCampaign into webhook + cron sweep automatic paths</name>
  <files>lib/services/webhook-service.ts, lib/services/phishing-sweep-service.ts</files>
  <read_first>
    - lib/services/webhook-service.ts (the triggerPhishingDetection(payload) private method, ~lines 395-491, and its fire-and-forget .catch() caller ~line 118)
    - lib/services/phishing-sweep-service.ts (the `for (const row of candidates.rows)` sweep loop, ~lines 78-84, and its per-ticket try/catch + result.errors accounting)
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md sections 7 and 8 (exact call sites + diffs)
  </read_first>
  <action>
    In `lib/services/webhook-service.ts`: add `import { groupReportIntoCampaign } from './campaign-grouping-service';` near the existing phishing-detector import. Inside `triggerPhishingDetection(payload)`, capture the detection result (`const detection = await detectPhishingTicket(ticket)`) and, when `detection.flagged && detection.reportId`, `await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true })` (D-01 automatic path, D-08 short-circuit). No new try/catch is needed — this method is already fire-and-forget with a `.catch()` at the caller. In `lib/services/phishing-sweep-service.ts`: add the same import. Inside the existing per-ticket loop's try block, after the detect + result-counter logic, when `detection.flagged && detection.reportId`, `await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true })`. Keep the grouping call inside the SAME try/catch as detectPhishingTicket so a grouping failure counts against `result.errors` and does not abort the sweep loop (matching the file's existing per-ticket error isolation). Both automatic paths pass `skipIfAlreadyGrouped: true` (D-08) — only the /analyze route (Task 1) omits it.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -c 'groupReportIntoCampaign' lib/services/webhook-service.ts lib/services/phishing-sweep-service.ts</automated>
  </verify>
  <done>Both service files import and call groupReportIntoCampaign with skipIfAlreadyGrouped:true on the flagged-report path; sweep call is inside the existing per-ticket try/catch; tsc clean.</done>
  <acceptance_criteria>
    - webhook-service.ts contains `import { groupReportIntoCampaign } from './campaign-grouping-service'` and a call `groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true })`
    - phishing-sweep-service.ts contains the same import and a call with `{ skipIfAlreadyGrouped: true }` inside the per-ticket try block (grep confirms the call precedes the catch that increments result.errors)
    - Neither automatic-path call omits skipIfAlreadyGrouped
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → /api/phishing/tickets/{id}/analyze | authenticated staff request; must carry phishing:analyze permission |
| ticket_id path param → SQL | untrusted numeric input crosses into a parameterized query |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-01 | Elevation of Privilege | POST /analyze | mitigate | `requirePermission('phishing', 'analyze')` first-line gate returns 403 for user-role (D-05 grants user read only) and 401 unauthenticated — verified by manual curl |
| T-18-05 | Information Disclosure | ticket_id path param | mitigate | `Number(ticket_id)` + `Number.isFinite` guard returns 400 before any query; SELECT uses `$1` parameterized placeholder — a malformed value never reaches SQL text or leaks a 500 |
| T-18-02 | Information Disclosure (IDOR) | analyze by arbitrary ticket_id | accept | single-tenant internal MSP tool (Wulf staff only); consistent with existing tickets/[id] route which does not tenant-scope — no new gap introduced |

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

<verification>
- `npx tsc --noEmit --pretty` clean across all three files
- `grep -c 'groupReportIntoCampaign'` returns >=1 in both webhook-service.ts and phishing-sweep-service.ts
- Manual (pre-verify gate, DETECT-03/ACCESS-01): curl the analyze route — 200 with admin cookie on a known phishing-pattern ticket (response has reportId/campaignId/groupMethod/created), 401 with no cookie, 403 with a user-role cookie, 400 on non-numeric ticket_id
</verification>

<success_criteria>
- POST /api/phishing/tickets/{ticket_id}/analyze orchestrates detect->parse->group and returns camelCase campaign linkage (DETECT-03)
- Automatic webhook + cron sweep paths call the shared grouping function so campaigns accumulate without an API call (D-01, CAMP-01/CAMP-02)
- The analyze route enforces requirePermission('phishing','analyze') (ACCESS-01)
</success_criteria>

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