---
phase: 18-campaign-grouping-phishing-analysis-api
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - lib/services/campaign-grouping-service.ts
  - lib/services/campaign-grouping-service.test.ts
  - lib/permissions.ts
autonomous: true
requirements: [CAMP-01, CAMP-02, ACCESS-01]

must_haves:
  truths:
    - "normalizeSubject strips repeated Re:/Fwd:/Fw: prefixes case-insensitively, lowercases, and trims"
    - "extractUrlDomain returns the hostname for a valid URL and null (never throws) for a malformed URL"
    - "A second report whose tiered key matches an existing campaign increments that campaign's report_count and updates last_seen_at, links reports.campaign_id, and never creates a second campaign"
    - "groupReportIntoCampaign called with skipIfAlreadyGrouped short-circuits (returns null) when reports.campaign_id is already set"
    - "The phishing permission resource exists in lib/permissions.ts statement with actions read/analyze/approve/remediate; admin+super-admin roles grant read+analyze, user role grants read only"
  artifacts:
    - path: "lib/services/campaign-grouping-service.ts"
      provides: "groupReportIntoCampaign(reportId, opts), normalizeSubject, extractUrlDomain"
      exports: ["groupReportIntoCampaign", "normalizeSubject", "extractUrlDomain", "GroupReportResult"]
    - path: "lib/services/campaign-grouping-service.test.ts"
      provides: "Unit tests for pure helpers + mocked-DB grouping behavior (CAMP-01, CAMP-02)"
      contains: "vi.mock('./postgres-client'"
    - path: "lib/permissions.ts"
      provides: "phishing resource + role grants (D-05)"
      contains: "phishing:"
  key_links:
    - from: "lib/services/campaign-grouping-service.ts"
      to: "postgresClient.transaction"
      via: "find-or-create wrapped in a single transaction (Pitfall 2 — no UNIQUE on campaign_key)"
      pattern: "postgresClient\\.transaction"
    - from: "lib/permissions.ts statement"
      to: "phishing resource key"
      via: "keyof typeof statement picks up phishing for requirePermission type-checking"
      pattern: "phishing:\\s*\\["
---

<objective>
Build the shared campaign-grouping core that every trigger site (webhook, cron sweep, on-demand /analyze) will call, plus the `phishing` permission vocabulary the API routes gate on. This is the foundation plan — no dependents exist yet, and both Wave 2 plans depend on the artifacts here.

Purpose: `groupReportIntoCampaign(reportId)` is the single source of truth for tiered campaign matching (CAMP-01) and campaign accumulation (CAMP-02). The `phishing` resource in `lib/permissions.ts` (D-05) establishes the full action vocabulary once so Phase 20 only adds role grants (ACCESS-01 foundation).
Output: `lib/services/campaign-grouping-service.ts`, its test file, and the modified `lib/permissions.ts`.
</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

<interfaces>
<!-- Contracts the executor needs. Extracted from codebase this session. -->

From lib/services/phishing-detector.ts (existing — DO NOT modify):
  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 interface DetectPhishingResult { flagged: boolean; reportId?: string; skippedUnchanged?: boolean; ... }
  export async function detectPhishingTicket(ticket: DetectableTicket): Promise<DetectPhishingResult>
  // Style precedent: computePhishingContentHash / matchesPhishingPatterns are small pure exported helpers alongside one DB-touching orchestrator; console.error + rethrow error handling.

From lib/services/postgres-client.ts (existing):
  import { postgresClient } from './postgres-client';   // named+relative import — matches sibling lib/services files
  postgresClient.query<T>(sql, params): Promise<{ rows: T[] }>
  postgresClient.transaction(async (client) => { ... })  // BEGIN/COMMIT/ROLLBACK wrapper; use client.query inside, NOT postgresClient.query

Schema (migrations/097_phishing_triage_schema.sql, 099_indicators_metadata.sql):
  campaigns(id UUID PK, campaign_key TEXT, group_method TEXT, first_seen_at TIMESTAMPTZ, last_seen_at TIMESTAMPTZ, report_count INTEGER DEFAULT 0, status TEXT DEFAULT 'open', created_at, updated_at)  -- NO UNIQUE on campaign_key (only a plain index)
  reports(id UUID PK, ticket_id BIGINT, ticket_number, company_id, company_name, requester_contact_id BIGINT, title, description, campaign_id UUID nullable FK, ...)  -- sender join column is requester_contact_id (NOT contact_id — Pitfall 5)
  messages(id UUID PK, report_id UUID FK, message_id TEXT nullable, headers JSONB, urls JSONB, attachments JSONB, body_preview, ...)  -- messages.message_id = email Message-ID header (Tier 1 key)
  indicators(id UUID PK, message_id UUID FK->messages(id), indicator_type TEXT ('attachment_hash'|'url'|'sender'), value TEXT, metadata JSONB, ...)  -- value for 'url' is a RAW URL string, not a domain (Pitfall 4)
  contacts(id, email_address VARCHAR, first_name, last_name)  -- from migrations/001_initial_schema.sql

From lib/permissions.ts (existing structure — closest analog is the rmm resource):
  export const statement = { ... rmm: ["read", "execute"] } as const;   // line ~30
  export const superAdminRole = ac.newRole({ ... rmm: ["read","execute"] });  // line ~46
  export const adminRole = ac.newRole({ ... rmm: ["read","execute"] });       // line ~59
  export const userRole = ac.newRole({ ... rmm: ["read"] });                  // line ~72
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Pure tier-key helpers (normalizeSubject, extractUrlDomain) + test file scaffold</name>
  <files>lib/services/campaign-grouping-service.ts, lib/services/campaign-grouping-service.test.ts</files>
  <read_first>
    - lib/services/phishing-detector.ts (pure-helper style: computePhishingContentHash, matchesPhishingPatterns — replicate this file shape and error handling)
    - lib/services/phishing-detector.test.ts (no-mock pure-function test style: plain describe/it/expect against exported helpers)
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md sections 1 and 2 (exact helper source + test scaffold to replicate)
  </read_first>
  <behavior>
    - normalizeSubject('Re: Your Invoice ') === 'your invoice'
    - normalizeSubject('FW: Re: fwd: Urgent Payment') === 'urgent payment' (repeated, mixed-case prefixes stripped)
    - normalizeSubject(null) === ''
    - extractUrlDomain('https://evil.example.com/path?x=1') === 'evil.example.com'
    - extractUrlDomain('not-a-url') === null (returns null, does not throw)
  </behavior>
  <action>
    Create `lib/services/campaign-grouping-service.ts` importing `{ postgresClient } from './postgres-client'` (named+relative, matching sibling lib/services files). Export `normalizeSubject(subject: string | null): string` — strip leading `Re:`/`Fwd:`/`Fw:` prefixes using a case-insensitive regex `/^(re|fwd|fw):\s*/i` applied repeatedly in a while loop, then lowercase and trim (D-03). Export `extractUrlDomain(url: string): string | null` — return `new URL(url).hostname || null` inside a try/catch that returns null on malformed input (Pitfall 4). Create `lib/services/campaign-grouping-service.test.ts` mocking `./postgres-client` via `vi.mock` BEFORE importing the module under test (queryMock + transactionMock spies per PATTERNS.md section 2), and add the pure-helper describe blocks covering the behavior cases above. Follow phishing-detector.ts's file-header doc-comment convention.
  </action>
  <verify>
    <automated>npx vitest run lib/services/campaign-grouping-service.test.ts</automated>
  </verify>
  <done>Both files exist; normalizeSubject and extractUrlDomain are exported; the pure-helper tests pass; the test file mocks ./postgres-client before import.</done>
  <acceptance_criteria>
    - campaign-grouping-service.ts contains `export function normalizeSubject(` and `export function extractUrlDomain(`
    - campaign-grouping-service.test.ts contains `vi.mock('./postgres-client'`
    - `npx vitest run lib/services/campaign-grouping-service.test.ts` exits 0 with the normalizeSubject and extractUrlDomain describe blocks green
    - normalizeSubject('FW: Re: fwd: Urgent Payment') returns 'urgent payment'
    - extractUrlDomain('not-a-url') returns null and does not throw
  </acceptance_criteria>
</task>

<task type="auto" tdd="true">
  <name>Task 2: groupReportIntoCampaign — tiered matching + transactional find-or-create</name>
  <files>lib/services/campaign-grouping-service.ts, lib/services/campaign-grouping-service.test.ts</files>
  <read_first>
    - lib/services/campaign-grouping-service.ts (current state from Task 1)
    - lib/services/campaign-grouping-service.test.ts (current state from Task 1)
    - lib/services/phishing-eml-service.ts (its file-header doc-comment block at lines 1-22 — replicate this "load-bearing limitation stated above the export" style for the D-07 comment; also its vi.mock test approach)
    - lib/services/postgres-client.ts (transaction() method, lines ~96-111)
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-RESEARCH.md Pattern 2 (tier query shapes) + Common Pitfalls 1-5
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md section 1 (GroupReportResult interface, function signature, schema facts)
  </read_first>
  <behavior>
    - Tier 1 (Message-ID): when the report has a linked messages row with a non-null message_id that matches another already-grouped report's message, groupReportIntoCampaign links to that existing campaign with groupMethod 'message_id' and does NOT create a new campaign
    - Tier 2 (attachment_hash/url-domain + normalized subject + sender within 24h): reached only when Tier 1 finds nothing; url-domain is derived from indicators.value via extractUrlDomain at read time (not queried as an exact domain)
    - Tier 3 (sender + normalizeSubject + client + 24h window): reached when Tiers 1-2 find nothing; joins reports.requester_contact_id -> contacts (Pitfall 5)
    - No-match: inserts a new campaigns row (campaign_key, group_method, first_seen_at=NOW, last_seen_at=NOW, report_count=1) and sets reports.campaign_id, returning created:true
    - Match: UPDATE campaigns SET report_count = report_count + 1, last_seen_at = NOW() and UPDATE reports SET campaign_id, returning created:false — never a second campaign row (CAMP-02)
    - skipIfAlreadyGrouped:true returns null immediately when reports.campaign_id IS NOT NULL (D-08); omitting the option always re-runs full tiered matching
  </behavior>
  <action>
    Extend `campaign-grouping-service.ts` with `export interface GroupReportResult { campaignId: string; groupMethod: 'message_id' | 'attachment_or_url' | 'sender_subject_client'; created: boolean; }` and `export async function groupReportIntoCampaign(reportId: string, opts?: { skipIfAlreadyGrouped?: boolean }): Promise<GroupReportResult | null>`. When `opts.skipIfAlreadyGrouped` is set, run a pre-check `SELECT campaign_id FROM reports WHERE id = $1` and return null if already grouped (D-08). Otherwise run the whole find-or-create inside `postgresClient.transaction(async (client) => { ... })` using `client.query` (never `postgresClient.query`) so all reads and writes share one transaction (Pitfall 2 — campaign_key has no UNIQUE constraint). Implement the three tiers in order, each gated on the previous returning nothing: Tier 1 keys on `messages.message_id` (join messages->reports where campaign_id IS NOT NULL AND reports.id != $reportId); Tier 2 keys on `indicators` rows (indicator_type 'attachment_hash' matched by value, 'url' matched by extractUrlDomain(value) at read time) plus normalizeSubject and sender within a 24h window (D-02), also excluding `reports.id = $reportId` from the candidate set; Tier 3 keys on sender (reports.requester_contact_id -> contacts) + normalizeSubject(title) + company_id within 24h (D-02), same `reports.id != $reportId` exclusion. Every tier query MUST exclude the report currently being grouped from its own candidate set (`AND r.id != $reportId` or equivalent) — without this, re-running `/analyze` on an already-grouped report (D-08 always re-runs, no skip) can match the report's own `messages`/`indicators` row against itself and double-increment `report_count` for the same report (plan-checker finding). Compute keys in JS then run targeted parameterized queries (never encode fuzzy tier logic in one WHERE clause — RESEARCH anti-pattern). On match, UPDATE campaigns (report_count+1, last_seen_at=NOW) + UPDATE reports.campaign_id + updated_at=NOW. On no match, INSERT a campaigns row with the computed campaign_key/group_method + link the report. Do NOT set/transition campaigns.status (leave the migration default 'open' — status transitions are Phase 19/20). Do NOT implement multi-campaign merge (D-04 — attach to first/best match). Add a file-level doc comment stating the D-07 limitation explicitly: the automatic webhook/cron path only ever reaches Tier 3 until a report has been through an explicit `/analyze` call, because parseAndStoreMessage (the only writer of messages/indicators) is not wired into the automatic path this phase. Wrap the body in try/catch with `console.error('[CAMPAIGN-GROUPING] ...', reportId, error)` + rethrow. Add mocked-DB tests to the test file covering the behavior cases above (transactionMock invokes its callback with a fake client whose query returns staged rows), including a test asserting that re-running `groupReportIntoCampaign(reportId)` (no `skipIfAlreadyGrouped`) on a report that is already linked to a campaign, and whose own `messages`/`indicators` rows would otherwise satisfy Tier 1 against itself, does NOT increment that campaign's `report_count` a second time for the same report.
  </action>
  <verify>
    <automated>npx vitest run lib/services/campaign-grouping-service.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <done>groupReportIntoCampaign + GroupReportResult exported; tiered matching, transactional find-or-create, skipIfAlreadyGrouped short-circuit, and D-07 doc comment all present; mocked-DB tests for CAMP-01/CAMP-02 pass; tsc clean.</done>
  <acceptance_criteria>
    - campaign-grouping-service.ts contains `export async function groupReportIntoCampaign(` and `export interface GroupReportResult`
    - The function body contains `postgresClient.transaction(` and references `report_count + 1` and `last_seen_at`
    - A file-level comment names the D-07 Tier-3-only automatic-path limitation (grep for `Tier 3` or `parseAndStoreMessage` in a comment near the top)
    - Test asserts: a matching second report increments report_count and does NOT insert a second campaign (transactionMock/queryMock call assertions)
    - Test asserts: skipIfAlreadyGrouped:true returns null when the pre-check SELECT reports a non-null campaign_id
    - Every tier query string in the implementation contains a self-exclusion clause (e.g. `!= $` against the report's own id) — grep the source for the exclusion parameter alongside each tier's WHERE clause
    - Test asserts: re-running groupReportIntoCampaign (no skipIfAlreadyGrouped) on an already-grouped report whose own messages/indicators would otherwise self-match does not increment report_count a second time for that report
    - `npx vitest run lib/services/campaign-grouping-service.test.ts` exits 0; `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 3: Add phishing permission resource + role grants to lib/permissions.ts</name>
  <files>lib/permissions.ts</files>
  <read_first>
    - lib/permissions.ts (full file — locate the existing rmm entries in statement, superAdminRole, adminRole, userRole; the phishing entries mirror rmm exactly in shape)
    - .planning/phases/18-campaign-grouping-phishing-analysis-api/18-PATTERNS.md section 6 (exact diff shape + Assumption A1 resolution)
  </read_first>
  <action>
    Add `phishing: ["read", "analyze", "approve", "remediate"]` to the `statement` object (full vocabulary now, mirroring the `rmm` line — D-05). Add `phishing: ["read", "analyze"]` to both `superAdminRole` and `adminRole` `ac.newRole({...})` calls. Add `phishing: ["read"]` to `userRole`. Do NOT grant `approve`/`remediate` to any role — those stay declared-only until Phase 20 (D-05, deferred). No other file needs editing: `lib/auth-utils.ts` imports `hasPermission`/`Permission`/`statement` from `./permissions`, so `keyof typeof statement` picks up `phishing` automatically for the routes' `requirePermission('phishing', ...)` type-checking (Assumption A1 confirmed in PATTERNS.md).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -c 'phishing:' lib/permissions.ts</automated>
  </verify>
  <done>phishing resource declared in statement with all four actions; read+analyze granted to admin+super-admin; read granted to user; approve/remediate ungranted; tsc clean.</done>
  <acceptance_criteria>
    - `grep -c 'phishing:' lib/permissions.ts` returns 4 (statement + 3 role definitions)
    - statement line reads `phishing: ["read", "analyze", "approve", "remediate"]`
    - superAdminRole and adminRole each contain `phishing: ["read", "analyze"]`
    - userRole contains `phishing: ["read"]`
    - No role grants `approve` or `remediate` for phishing (grep confirms neither appears in any newRole phishing entry)
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| API → Postgres | groupReportIntoCampaign builds SQL from report/indicator/contact data; all values must be parameterized |
| indicator value → grouping logic | indicators.value (attacker-controlled URL strings from parsed phishing emails) is read as a string for domain extraction — must never be fetched |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-04 | Tampering/SSRF | extractUrlDomain / Tier 2 matching | mitigate | indicators.value is only ever parsed with `new URL(value).hostname` for a string key — no network fetch is issued during grouping; guarded in try/catch (EVID-04 never-fetch invariant carried forward) |
| T-18-06 | Tampering | Tier query construction | mitigate | All tier queries use `$n` parameterized placeholders inside postgresClient.transaction — no user/indicator value is string-interpolated into SQL text |
| T-18-01 | Elevation of Privilege | phishing permission resource (D-05) | mitigate | approve/remediate declared in statement but ungranted to every role this phase; only read+analyze (admin/super-admin) and read (user) are grantable, so no route can be authorized for approve/remediate until Phase 20 |

No package installs this phase (zero new dependencies per RESEARCH.md) — the supply-chain (T-{phase}-SC) row is not applicable.
</threat_model>

<verification>
- `npx vitest run lib/services/campaign-grouping-service.test.ts` green (CAMP-01, CAMP-02 behaviors)
- `npx tsc --noEmit --pretty` clean
- `grep -c 'phishing:' lib/permissions.ts` returns 4
- Manual read confirms the D-07 Tier-3-only limitation is documented in a comment in campaign-grouping-service.ts
</verification>

<success_criteria>
- groupReportIntoCampaign implements Message-ID → attachment-hash/URL-domain+subject+sender+24h → sender+normalized-subject+client+24h tiered matching (CAMP-01)
- Matching reports increment report_count / update last_seen_at without creating a second campaign (CAMP-02)
- phishing resource + read/analyze/user-read grants exist; approve/remediate ungranted (ACCESS-01 foundation, D-05)
- All automated tests + tsc pass
</success_criteria>

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