feat(15-03): add bounded phishing sweep service

- sweepPhishingTickets() queries recently-modified tickets (7d window, LIMIT 500)
- delegates each ticket to shared detectPhishingTicket (no duplicated match/hash logic)
- per-row try/catch increments errors without aborting the loop
This commit is contained in:
lorentz 2026-07-15 07:47:08 -04:00
parent 191a8c7210
commit dbd2ebe63c

View file

@ -0,0 +1,101 @@
/**
* Phishing Sweep Service
*
* Bounded cron reconciliation sweep over recently-modified tickets, mirroring
* ticket-reconciliation-service.ts's structure. The webhook path (Plan 03,
* webhook-service.ts) is the primary near-real-time trigger; this sweep
* catches anything the webhook missed (dropped events, backfilled tickets,
* tickets whose content changed after the webhook already fired) by
* re-running the same shared detectPhishingTicket core.
*
* No duplicated pattern-matching/hashing logic here this file only
* queries candidate tickets and delegates detection to phishing-detector.ts.
*/
import { postgresClient } from './postgres-client';
import { detectPhishingTicket, type DetectableTicket } from './phishing-detector';
import { createSyncLogger } from '../utils/sync-logger';
export interface PhishingSweepResult {
scanned: number;
flagged: number;
skippedUnchanged: number;
errors: number;
}
const SWEEP_WINDOW_DAYS = 7;
const SCAN_LIMIT = 500;
/**
* Re-scan up to SCAN_LIMIT recently-modified tickets through the shared
* phishing detector. Bounded and idempotent safe to run on a recurring
* schedule alongside the webhook trigger.
*/
export async function sweepPhishingTickets(): Promise<PhishingSweepResult> {
const logger = createSyncLogger({ component: 'PhishingSweep' });
const startedAt = Date.now();
const result: PhishingSweepResult = {
scanned: 0,
flagged: 0,
skippedUnchanged: 0,
errors: 0,
};
const candidatesQuery = `
SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id
FROM tickets
WHERE is_deleted = false
AND last_activity_date > NOW() - INTERVAL '${SWEEP_WINDOW_DAYS} days'
ORDER BY last_activity_date DESC
LIMIT $1
`;
const candidates = 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;
}>(candidatesQuery, [SCAN_LIMIT]);
logger.info(
`Sweeping ${candidates.rows.length} recently-modified tickets (>${SWEEP_WINDOW_DAYS}d, limit=${SCAN_LIMIT})`
);
for (const row of candidates.rows) {
result.scanned += 1;
const ticket: DetectableTicket = {
id: Number(row.id),
ticket_number: row.ticket_number,
title: row.title,
description: row.description,
company_id: row.company_id,
contact_id: row.contact_id,
created_by_contact_id: row.created_by_contact_id,
};
try {
const detection = await detectPhishingTicket(ticket);
if (detection.skippedUnchanged) {
result.skippedUnchanged += 1;
} else if (detection.flagged) {
result.flagged += 1;
}
} catch (err) {
result.errors += 1;
logger.warn(
`Phishing detection failed for ticket ${ticket.id}`,
{ ticketId: ticket.id },
err instanceof Error ? err : new Error(String(err))
);
}
}
logger.info(
`Phishing sweep complete: scanned=${result.scanned} flagged=${result.flagged} skippedUnchanged=${result.skippedUnchanged} errors=${result.errors}`,
{ duration: Date.now() - startedAt }
);
return result;
}