---
phase: 15-data-model-detection-ticket-evidence
plan: 03
subsystem: services
tags: [phishing-triage, webhook, cron, scheduler, detection]

# Dependency graph
requires:
  - phase: 15-02
    provides: "detectPhishingTicket(ticket) shared detection core (phishing-detector.ts)"
provides:
  - "lib/services/phishing-sweep-service.ts — sweepPhishingTickets() bounded cron reconciliation"
  - "webhook-service.ts triggerPhishingDetection() — fire-and-forget detection on ticket.created"
  - "sync-scheduler.ts phishing-sweep sync_type + dispatch branch + defaultSchedules entry"
  - "migrations/098_phishing_sweep_schedule.sql — phishing-sweep schedule seed for existing installs"
affects: [16-message-parsing, 17-mimecast-blast-radius]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "Fire-and-forget webhook hook-in mirroring triggerWorkflowEngine (.catch(err => console.error(...)), never awaited in the request path"
    - "Bounded cron sweep mirroring reconcileStaleTickets (LIMIT + recent-activity window + per-row try/catch, never rethrow)"
    - "Dynamic await import() in every sync-scheduler dispatch branch (no eager worker import per CLAUDE.md)"

key-files:
  created: [lib/services/phishing-sweep-service.ts, migrations/098_phishing_sweep_schedule.sql]
  modified: [lib/services/webhook-service.ts, lib/services/sync-scheduler.ts]

key-decisions:
  - "Reused ticket-reconciliation-service.ts's structure verbatim for the sweep (SELECT-with-LIMIT, per-row try/catch, createSyncLogger, aggregate result object) rather than inventing a new shape"
  - "triggerPhishingDetection reads payload.entity.createdByContactID (not creatorContactID, which does not exist) into created_by_contact_id — verified by grep since a typo would compile cleanly (payload.entity is Record<string, any>) but silently degrade EVID-01 requester capture at runtime"
  - "phishing-sweep schedule is registered disabled-by-default (is_enabled: false), matching the tickets-reconcile precedent — an admin must opt in via /admin"

requirements-completed: [DETECT-01, DETECT-02]

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

# Phase 15 Plan 03: Webhook + Cron Sweep Wiring Summary

**Wired the Plan 02 `detectPhishingTicket` core into both of Pulse's established scan triggers — a fire-and-forget hook on the ticket.created webhook and a bounded daily cron sweep (`sweepPhishingTickets`, LIMIT 500 / 7-day window) — plus migration 098 to seed the disabled-by-default `phishing-sweep` schedule row for existing installs.**

## Performance

- **Duration:** 9 min
- **Started:** 2026-07-15T11:49:00Z
- **Completed:** 2026-07-15T11:58:00Z
- **Tasks:** 3 completed
- **Files modified:** 4 (2 created, 2 modified)

## Accomplishments
- Created `lib/services/phishing-sweep-service.ts` exporting `sweepPhishingTickets()`: queries non-deleted tickets with `last_activity_date` in the last 7 days (bounded `LIMIT 500`), calls the shared `detectPhishingTicket` per row inside a try/catch (no rethrow — one bad ticket never aborts the sweep), and returns a `{ scanned, flagged, skippedUnchanged, errors }` aggregate, logged via `createSyncLogger`.
- Wired `webhook-service.ts`: added a `triggerPhishingDetection` private method that builds a `DetectableTicket` from `payload.entity` (preferring the inline entity, falling back to `payload.entityId` with null fields), reading the correct `createdByContactID` Autotask field into `created_by_contact_id`. Called it as a second fire-and-forget alongside the existing `triggerWorkflowEngine` call in the `ticket.created` handler, without awaiting it in the request path.
- Registered the sweep in `sync-scheduler.ts`: extended the `sync_type` union with `'phishing-sweep'`, added a `defaultSchedules` entry (`is_enabled: false`, daily `0 5 * * *` cron), and added a dispatch branch that dynamically imports and calls `sweepPhishingTickets`, logging the scanned/flagged/skippedUnchanged/errors summary.
- Created `migrations/098_phishing_sweep_schedule.sql`, seeding the `phishing-sweep` row via `ON CONFLICT (id) DO NOTHING` (idempotent, disabled by default) for existing installs whose `sync_schedules` table predates this migration.

## Task Commits

1. **Task 1: phishing-sweep-service.ts** - `dbd2ebe` (feat)
2. **Task 2: webhook hook-in** - `194b58b` (feat)
3. **Task 3: scheduler branch + migration 098** - `b199d99` (feat)

**Plan metadata:** (this SUMMARY.md commit)

## Files Created/Modified
- `lib/services/phishing-sweep-service.ts` - Bounded reconciliation sweep calling the shared `detectPhishingTicket` core (no duplicated match/hash logic)
- `lib/services/webhook-service.ts` - Added `detectPhishingTicket` import, `triggerPhishingDetection` method, and a fire-and-forget call in the `ticket.created` handler alongside the existing workflow-engine trigger
- `lib/services/sync-scheduler.ts` - Extended `sync_type` union, added `phishing-sweep` `defaultSchedules` entry, added a dispatch branch dynamically importing `sweepPhishingTickets`
- `migrations/098_phishing_sweep_schedule.sql` - Seeds the `phishing-sweep` schedule row for existing installs (idempotent, disabled by default)

## Decisions Made
- Mirrored `ticket-reconciliation-service.ts`'s structure for the sweep verbatim (module constants for window/limit, `createSyncLogger`, per-row try/catch with `logger.warn` and no rethrow) rather than introducing a different shape, since the plan explicitly called this out as the closest analog.
- Used the exact `createdByContactID` field name confirmed by the plan's interface notes and `lib/utils/entity-mapper.ts:211`; verified via grep that no `creatorContactID` typo was introduced (a typo would compile cleanly since `AutotaskWebhookPayload.entity` is `Record<string, any>`, but would silently produce `undefined` at runtime).
- Kept the `phishing-sweep` schedule disabled by default (`is_enabled: false`), matching the `tickets-reconcile` precedent, so the sweep does nothing until an admin explicitly enables it via `/admin`.

## Deviations from Plan
None — plan executed exactly as written. All three tasks matched their `<action>` specs, and every acceptance criterion (grep checks + `npx tsc --noEmit --pretty`) passed on first attempt.

## Issues Encountered
None.

## User Setup Required
None for this plan. The `phishing-sweep` schedule is seeded disabled; an admin can enable it later at `/admin` once ready to run reconciliation sweeps in production. No new env vars or credentials introduced (reuses existing `AUTOTASK_*` config via the Plan 02 detector).

## Next Phase Readiness
- Both DETECT-01 scan triggers (webhook + cron) are now wired to the same shared `detectPhishingTicket` core, so DETECT-02 idempotency holds identically on either path.
- The `phishing-sweep` schedule exists in the `defaultSchedules` seed path (fresh installs) and migration 098 (existing installs), both idempotent and disabled by default.
- No blockers for Phase 16 (message parsing) or Phase 17 (Mimecast blast-radius), which build on this detection/evidence foundation.

---
*Phase: 15-data-model-detection-ticket-evidence*
*Completed: 2026-07-15*

## Self-Check: PASSED
- FOUND: lib/services/phishing-sweep-service.ts
- FOUND: migrations/098_phishing_sweep_schedule.sql
- FOUND: .planning/phases/15-data-model-detection-ticket-evidence/15-03-SUMMARY.md
- FOUND: commit dbd2ebe
- FOUND: commit 194b58b
- FOUND: commit b199d99
