---
phase: 18-campaign-grouping-phishing-analysis-api
plan: 02
subsystem: api
tags: [nextjs, postgres, phishing, campaign-grouping, permissions]

# Dependency graph
requires:
  - phase: 18-campaign-grouping-phishing-analysis-api (plan 01)
    provides: "groupReportIntoCampaign(reportId, opts) in lib/services/campaign-grouping-service.ts + phishing permission resource in lib/permissions.ts"
  - phase: 16-eml-mime-evidence-parser
    provides: "parseAndStoreMessage() writer of messages/indicators rows"
provides:
  - "POST /api/phishing/tickets/{ticket_id}/analyze — on-demand detect->parse->group orchestration for one ticket (DETECT-03)"
  - "Automatic campaign accumulation wired into the webhook ticket.created path and the cron sweep loop (D-01, CAMP-01/CAMP-02)"
affects: [18-03, 19-classification, 20-remediation]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "/analyze route omits skipIfAlreadyGrouped (always re-run, may upgrade Tier-3 -> Tier-1/2); automatic webhook/cron paths always pass skipIfAlreadyGrouped:true (D-08)"
    - "Route reconstructs a DetectableTicket from the tickets table (no Autotask payload available), mirroring webhook-service.ts's existing read-back pattern"

key-files:
  created:
    - app/api/phishing/tickets/[ticket_id]/analyze/route.ts
  modified:
    - lib/services/webhook-service.ts
    - lib/services/phishing-sweep-service.ts

key-decisions:
  - "Grouping call in phishing-sweep-service.ts placed inside the SAME try/catch as detectPhishingTicket so a grouping failure counts against result.errors without aborting the per-ticket sweep loop"
  - "webhook-service.ts needed no new try/catch — triggerPhishingDetection is already fire-and-forget with a .catch() at its caller"

requirements-completed: [DETECT-03, CAMP-01, CAMP-02, ACCESS-01]

# Metrics
duration: 12min
completed: 2026-07-15
---

# Phase 18 Plan 02: On-Demand Analyze Route + Automatic Grouping Wiring Summary

**POST /api/phishing/tickets/{ticket_id}/analyze orchestrates detect->parse->group for one ticket, and groupReportIntoCampaign is now called automatically from both the webhook ticket.created path and the cron sweep loop so campaigns accumulate without any API call.**

## Performance

- **Duration:** ~12 min
- **Started:** 2026-07-15T23:19Z (approx, first commit)
- **Completed:** 2026-07-15T23:31Z (last task commit)
- **Tasks:** 2/2 completed
- **Files modified:** 3 (1 created, 2 modified)

## Accomplishments
- `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` — new POST route gated by `requirePermission('phishing', 'analyze')` (D-06); validates `ticket_id` (400 for non-numeric), looks up the ticket row directly from Postgres (no Autotask payload available to this route), runs `detectPhishingTicket` (400 if the ticket doesn't match phishing patterns), `parseAndStoreMessage` (its `{ stored: false, reason }` no-op result is not treated as an error), then `groupReportIntoCampaign(detection.reportId)` with no `skipIfAlreadyGrouped` (D-08 — always re-runs so a previously Tier-3-only grouping can upgrade). Returns camelCase `{ reportId, campaignId, groupMethod, created }`.
- `lib/services/webhook-service.ts` — `triggerPhishingDetection` now captures the `detectPhishingTicket` result and calls `groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true })` when flagged, so campaigns accumulate automatically off the webhook path (D-01) without a redundant re-group of an already-linked report (D-08).
- `lib/services/phishing-sweep-service.ts` — the same call added inside the existing per-ticket try block of the cron sweep loop, with `skipIfAlreadyGrouped: true`; kept inside the pre-existing try/catch so a grouping failure is counted in `result.errors` and does not abort the sweep of remaining tickets.

## Task Commits

Each task was committed atomically:

1. **Task 1: POST /api/phishing/tickets/[ticket_id]/analyze route** - `de013e6` (feat)
2. **Task 2: Wire groupReportIntoCampaign into webhook + cron sweep automatic paths** - `19b8b4b` (feat)

**Plan metadata:** committed alongside this SUMMARY (see final commit in this plan's history)

## Files Created/Modified
- `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` - New POST route; detect -> parse -> group orchestration, camelCase response, ticket_id validation, 401/403/404/400/500 status handling
- `lib/services/webhook-service.ts` - Added `groupReportIntoCampaign` import + call at the end of `triggerPhishingDetection` (skipIfAlreadyGrouped:true)
- `lib/services/phishing-sweep-service.ts` - Added `groupReportIntoCampaign` import + call inside the per-ticket sweep try block (skipIfAlreadyGrouped:true)

## Decisions Made
- No deviations from the exact route/diff shapes specified in 18-PATTERNS.md sections 3, 7, and 8 — implementation matches the pattern map verbatim including import-style split (relative import in `lib/services/`, absolute-alias import in the route file).

## Deviations from Plan

None - plan executed exactly as written.

## Issues Encountered

None. `npx tsc --noEmit --pretty` was clean after each task. Existing vitest suites (`campaign-grouping-service.test.ts`, `phishing-detector.test.ts`, `phishing-eml-service.test.ts` — 39 tests) still pass unchanged; no test files exist for `webhook-service.ts` or `phishing-sweep-service.ts` (none were expected — `vitest.config.ts` scope and repo precedent both exclude route-adjacent/webhook test coverage per 18-PATTERNS.md's noted test-scope boundary).

## User Setup Required

None - no external service configuration required.

## Manual Verification (Deferred)

The plan's `<verify><human-check>` step calls for curling the live route with three distinct session states (no cookie / user-role cookie / admin-role cookie) against a running dev server and a real phishing-pattern ticket row. This worktree agent has no interactive browser session to mint those cookies and the only reachable Postgres/app containers are the shared long-running `pulse-app`/`pulse-postgres` stack (running the `master` build, not this worktree's uncommitted code) — exercising it directly would risk interfering with concurrent work outside this plan's scope. What was verified instead:
- `npx tsc --noEmit --pretty` clean across all three files (route + both modified services).
- `requirePermission('phishing', 'analyze')` is the first statement in the handler with `if (error) return error;`, matching the exact gate shape already proven correct for `rmm/executions`'s POST handler (same helper, same call pattern) — 401 (no session) / 403 (user role lacks `analyze`, only `read` per Plan 01's `lib/permissions.ts` grants) are enforced by that shared helper, not by any new logic in this route.
- `grep -c 'groupReportIntoCampaign'` returns 2 in both `webhook-service.ts` and `phishing-sweep-service.ts` (import + call), confirmed by `<automated>` verify.
This is flagged here, not silently skipped, for the phase verifier to confirm with a live curl pass when the build is deployed.

## Next Phase Readiness
- `/api/phishing/tickets/{ticket_id}/analyze` is ready for Plan 03's campaign list/detail routes to link from, and is the first live caller of Phase 16's `parseAndStoreMessage`.
- Automatic campaign accumulation (D-01) is now live on both the webhook and cron sweep paths — Plan 03's campaign list/detail endpoints will see `report_count`/`last_seen_at` update without any explicit `/analyze` call, satisfying CAMP-01/CAMP-02 end-to-end.
- Known carried-forward limitation (documented in Plan 01, unchanged by this plan): the automatic webhook/cron path only reaches Tier 3 grouping until a report has gone through an explicit `/analyze` call at least once (Tier 1/2 need `messages`/`indicators` rows that only `parseAndStoreMessage` writes, and that function is only called from the new `/analyze` route this plan added).

---
*Phase: 18-campaign-grouping-phishing-analysis-api*
*Completed: 2026-07-15*

## Self-Check: PASSED

All created/modified files confirmed present on disk; both task commit hashes (`de013e6`, `19b8b4b`) confirmed present in git log.
