feat(23-05): wire gated parse->classify->acknowledge chain into webhook

- triggerPhishingDetection now captures groupReportIntoCampaign's result and,
  when a campaignId exists, calls new runGatedPhishingStages
- runGatedPhishingStages reads the per-company automation gate and
  conditionally runs parseAndStoreMessage, classifyCampaign, and (only for
  USER_AWARENESS verdicts) generateAndPostAcknowledgment
- each stage isolated in its own try/catch (T-23-09); detection + grouping
  remain unconditional (D-07); auto_report never posts any other action
  (D-04, T-23-08)
This commit is contained in:
lorentz 2026-07-16 19:46:15 -04:00
parent e0f22f27c9
commit e1193bf476

View file

@ -16,6 +16,10 @@ 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';
import { getCompanyAutomationGate } from './phishing-automation-gate';
import { parseAndStoreMessage } from './phishing-eml-service';
import { classifyCampaign, Verdict } from './campaign-classifier';
import { generateAndPostAcknowledgment } from './triage-note-service';
export class WebhookService {
private _autotaskClient: AutotaskClient | null = null;
@ -490,7 +494,75 @@ export class WebhookService {
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 });
const grouped = await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });
if (grouped?.campaignId) {
await this.runGatedPhishingStages({
campaignId: grouped.campaignId,
companyId: r.company_id,
reportId: detection.reportId,
ticketId: Number(r.id),
});
}
}
}
/**
* Phase 23 D-04/D-06/D-07: runs the opted-in parse -> classify -> report
* chain for a company after detection + grouping have already run
* unconditionally. Each stage is independently gated by
* `phishing_automation_gate` and isolated in its own try/catch so a
* failure in one stage never blocks the webhook response or aborts a
* later stage (T-23-09).
*
* auto_report auto-posts EXCLUSIVELY the acknowledge_user thank-you note,
* and only when the campaign's current verdict is USER_AWARENESS (D-04).
* Every other verdict/action remains proposed-only and manual-approval
* gated this method never calls approve/remediate/block/purge/warn_user.
*/
private async runGatedPhishingStages(input: {
campaignId: string;
companyId: number | null;
reportId: string;
ticketId: number;
}): Promise<void> {
const { campaignId, companyId, reportId, ticketId } = input;
const gate = await getCompanyAutomationGate(companyId);
if (gate.autoParse) {
try {
await parseAndStoreMessage({ reportId, ticketId });
} catch (err) {
console.error('[WEBHOOK] auto_parse stage error', err);
}
}
let verdict: Verdict | null = null;
if (gate.autoClassify) {
try {
const result = await classifyCampaign(campaignId);
verdict = result.verdict;
} catch (err) {
console.error('[WEBHOOK] auto_classify stage error', err);
}
}
if (gate.autoReport) {
try {
if (verdict === null) {
const latest = await postgresClient.query<{ verdict: string | null }>(
`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`,
[campaignId]
);
verdict = (latest.rows[0]?.verdict as Verdict | undefined) ?? null;
}
if (verdict === 'USER_AWARENESS') {
await generateAndPostAcknowledgment(campaignId);
}
} catch (err) {
console.error('[WEBHOOK] auto_report stage error', err);
}
}
}
}