chore: merge executor worktree (worktree-agent-a9f362ad9e34b1a2a)
This commit is contained in:
commit
c0967b5a9c
4 changed files with 212 additions and 1 deletions
|
|
@ -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 `<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.
|
||||
90
app/api/phishing/tickets/[ticket_id]/analyze/route.ts
Normal file
90
app/api/phishing/tickets/[ticket_id]/analyze/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PhishingSweepResult> {
|
|||
} 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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue