import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../../platform/platform-graphql.service'; import type { ActionHandler, ActionResult } from '../types/action.types'; import type { RuleAction, EscalateActionParams } from '../types/rule.types'; /** * Persists a PerformanceAlert when a rule's escalate action fires. * * Dedupes by (agentId, alertType, IST date) — a single rule firing every * 5 min should only produce ONE alert per day per agent until dismissed. * If a row already exists for that key today and is not dismissed, the * action is a no-op (returns the existing id). If the existing row was * dismissed earlier today, we don't re-fire — supervisor explicitly * acknowledged. */ @Injectable() export class EscalateActionHandler implements ActionHandler { type = 'escalate'; private readonly logger = new Logger(EscalateActionHandler.name); constructor(private readonly platform: PlatformGraphqlService) {} async execute(action: RuleAction, context: Record): Promise { const params = action.params as EscalateActionParams & { ruleId?: string; alertType?: string }; const agentId = context['agent.id'] as string | undefined; const agentName = (context['agent.name'] as string | undefined) ?? ''; const valueRaw = context['_alertValue']; const valueText = valueRaw != null ? String(valueRaw) : null; if (!agentId) { return { success: false, error: 'agent.id missing from facts' }; } const alertType = params.alertType ?? this.inferAlertType(params.message); const severity = (params.severity ?? 'warning').toUpperCase(); // INFO | WARNING | CRITICAL const today = this.todayIst(); // Dedupe: any non-dismissed alert today for this agent + type? try { const existing = await this.platform.query( `{ performanceAlerts(first: 1, filter: { agentId: { eq: "${agentId}" }, alertType: { eq: ${alertType} }, firedAt: { gte: "${today}T00:00:00+05:30", lte: "${today}T23:59:59+05:30" } }) { edges { node { id dismissedAt value } } } }`, ); const existingNode = existing?.performanceAlerts?.edges?.[0]?.node; if (existingNode) { // Already fired today. If value changed, update it; otherwise no-op. if (!existingNode.dismissedAt && existingNode.value !== valueText) { await this.platform.query( `mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`, { id: existingNode.id, data: { value: valueText } }, ); } return { success: true, data: { id: existingNode.id, deduped: true, agentId, alertType } }; } const created = await this.platform.query( `mutation($data: PerformanceAlertCreateInput!) { createPerformanceAlert(data: $data) { id } }`, { data: { name: `${agentName || agentId}: ${params.message ?? alertType}${valueText ? ` (${valueText})` : ''}`, agentId, alertType, severity, message: params.message ?? alertType, value: valueText, ruleId: params.ruleId ?? null, firedAt: new Date().toISOString(), }, }, ); const id = created?.createPerformanceAlert?.id; this.logger.log(`[ESCALATE] Created alert ${id} agent=${agentName ?? agentId} type=${alertType} value=${valueText}`); return { success: true, data: { id, agentId, alertType, severity, message: params.message } }; } catch (err: any) { this.logger.warn(`[ESCALATE] Failed for agent=${agentId}: ${err?.message ?? err}`); return { success: false, error: String(err?.message ?? err) }; } } private inferAlertType(message: string | undefined): string { const m = (message ?? '').toLowerCase(); if (m.includes('idle')) return 'EXCESSIVE_IDLE'; if (m.includes('nps')) return 'LOW_NPS'; if (m.includes('conversion')) return 'LOW_CONVERSION'; return 'OTHER'; } private todayIst(): string { const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000); return ist.toISOString().slice(0, 10); } }