From de013e6ec140d752c9fa67a26f109cc1ba168752 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:30:46 -0400 Subject: [PATCH 1/3] feat(18-02): add POST /api/phishing/tickets/[ticket_id]/analyze route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Orchestrates detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign - requirePermission('phishing','analyze') gate first-line (D-06, 401/403) - Validates ticket_id numeric (400), missing ticket (404), non-phishing ticket (400) - No skipIfAlreadyGrouped (D-08) — always re-runs grouping on demand --- .../tickets/[ticket_id]/analyze/route.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/api/phishing/tickets/[ticket_id]/analyze/route.ts diff --git a/app/api/phishing/tickets/[ticket_id]/analyze/route.ts b/app/api/phishing/tickets/[ticket_id]/analyze/route.ts new file mode 100644 index 0000000..585cc4d --- /dev/null +++ b/app/api/phishing/tickets/[ticket_id]/analyze/route.ts @@ -0,0 +1,90 @@ +/** + * POST /api/phishing/tickets/{ticket_id}/analyze + * + * On-demand trigger for one ticket: detect -> parse EML -> group into + * campaign. Unlike the automatic webhook/cron paths, this always re-runs + * groupReportIntoCampaign (no skipIfAlreadyGrouped) since parseAndStoreMessage + * may have just written new messages/indicators rows that allow a Tier-3 + * grouping to upgrade to Tier-1/Tier-2 (D-08). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { detectPhishingTicket, type DetectableTicket } from '@/lib/services/phishing-detector'; +import { parseAndStoreMessage } from '@/lib/services/phishing-eml-service'; +import { groupReportIntoCampaign } from '@/lib/services/campaign-grouping-service'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ ticket_id: string }> } +) { + const { error } = await requirePermission('phishing', 'analyze'); + if (error) return error; + + const { ticket_id } = await params; + const ticketId = Number(ticket_id); + if (!Number.isFinite(ticketId)) { + return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 }); + } + + try { + const row = await postgresClient.query<{ + id: string; + ticket_number: string | null; + title: string | null; + description: string | null; + company_id: number | null; + contact_id: number | null; + created_by_contact_id: number | null; + }>( + `SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id + FROM tickets WHERE id = $1`, + [ticketId] + ); + const r = row.rows[0]; + if (!r) { + return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); + } + + const ticket: DetectableTicket = { + id: Number(r.id), + ticket_number: r.ticket_number, + title: r.title, + description: r.description, + company_id: r.company_id, + contact_id: r.contact_id, + created_by_contact_id: r.created_by_contact_id, + }; + + const detection = await detectPhishingTicket(ticket); + if (!detection.flagged || !detection.reportId) { + return NextResponse.json( + { error: 'Ticket does not match known phishing patterns' }, + { status: 400 } + ); + } + + // parseAndStoreMessage never throws for expected no-op cases (returns + // { stored: false, reason }) — grouping still proceeds regardless. + await parseAndStoreMessage({ reportId: detection.reportId, ticketId }); + + // D-08: /analyze always re-runs grouping unconditionally (no + // skipIfAlreadyGrouped) — allows a Tier-3 grouping to upgrade to Tier-1 + // now that messages/indicators rows may exist. + const grouped = await groupReportIntoCampaign(detection.reportId); + + return NextResponse.json({ + reportId: detection.reportId, + campaignId: grouped?.campaignId ?? null, + groupMethod: grouped?.groupMethod ?? null, + created: grouped?.created ?? false, + }); + } catch (err) { + console.error('[PHISHING-ANALYZE] Failed to analyze ticket', ticketId, err); + return NextResponse.json( + { error: 'Failed to analyze ticket', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +} From 19b8b4b4151fc9b969c272ae08db78a64a7edbbc Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:31:18 -0400 Subject: [PATCH 2/3] feat(18-02): wire groupReportIntoCampaign into webhook + cron sweep paths - webhook-service.ts: triggerPhishingDetection calls groupReportIntoCampaign with skipIfAlreadyGrouped:true after a flagged detection (D-01, D-08) - phishing-sweep-service.ts: per-ticket sweep loop calls the same, inside the existing try/catch so a grouping failure counts against result.errors without aborting the sweep --- lib/services/phishing-sweep-service.ts | 8 ++++++++ lib/services/webhook-service.ts | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/services/phishing-sweep-service.ts b/lib/services/phishing-sweep-service.ts index b598daa..26ea735 100644 --- a/lib/services/phishing-sweep-service.ts +++ b/lib/services/phishing-sweep-service.ts @@ -14,6 +14,7 @@ import { postgresClient } from './postgres-client'; import { detectPhishingTicket, type DetectableTicket } from './phishing-detector'; +import { groupReportIntoCampaign } from './campaign-grouping-service'; import { createSyncLogger } from '../utils/sync-logger'; export interface PhishingSweepResult { @@ -82,6 +83,13 @@ export async function sweepPhishingTickets(): Promise { } else if (detection.flagged) { result.flagged += 1; } + // D-01/D-08: grouping runs regardless of skippedUnchanged (a report + // could have been created by a previous sweep pass and still lack a + // campaign_id if grouping failed transiently that time); short-circuits + // internally if already grouped. + if (detection.flagged && detection.reportId) { + await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true }); + } } catch (err) { result.errors += 1; logger.warn( diff --git a/lib/services/webhook-service.ts b/lib/services/webhook-service.ts index bf7ff67..f523396 100644 --- a/lib/services/webhook-service.ts +++ b/lib/services/webhook-service.ts @@ -15,6 +15,7 @@ import { ticketWorkflowEngine } from './ticket-workflow-engine'; import '../services/workflow-steps'; // Register all workflow step executors import { WorkflowEvent, TicketData } from '../types/workflow'; import { detectPhishingTicket, DetectableTicket } from './phishing-detector'; +import { groupReportIntoCampaign } from './campaign-grouping-service'; export class WebhookService { private _autotaskClient: AutotaskClient | null = null; @@ -486,7 +487,11 @@ export class WebhookService { }; console.log(`[WEBHOOK] Triggering phishing detection for ticket ${payload.entityId}`); - await detectPhishingTicket(ticket); + const detection = await detectPhishingTicket(ticket); + // D-01/D-08: automatic path short-circuits if already grouped. + if (detection.flagged && detection.reportId) { + await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true }); + } } } From a544024e75eb6bac4fa8982ece44dd712b245500 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:33:00 -0400 Subject: [PATCH 3/3] docs(18-02): complete on-demand analyze route + automatic grouping wiring plan Co-Authored-By: Claude Sonnet 5 --- .../18-02-SUMMARY.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .planning/phases/18-campaign-grouping-phishing-analysis-api/18-02-SUMMARY.md diff --git a/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-02-SUMMARY.md b/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-02-SUMMARY.md new file mode 100644 index 0000000..3462945 --- /dev/null +++ b/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-02-SUMMARY.md @@ -0,0 +1,108 @@ +--- +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 `` 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 `` 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.