Files
support_backend/src/modules/orchestration/escalation/service/escalation.service.ts
T

138 lines
5.3 KiB
TypeScript
Raw Normal View History

2026-09-03 13:02:05 +05:30
import { randomUUID } from 'crypto';
import { EscalationEvent, EscalationPolicy, EscalationRule } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { ticketsService } from '@/modules/ticketing/tickets';
import { hierarchyRepository } from '@/modules/orchestration/hierarchy';
import { productsRepository } from '@/modules/catalog/products';
import { assignmentEngine, AssignmentEngine } from '@/modules/orchestration/assignments';
import { eventBus } from '@/events/event-bus';
import { DomainEventName } from '@/events/domain-events';
import {
escalationPolicyRepository,
EscalationPolicyRepository,
escalationRuleRepository,
EscalationRuleRepository,
escalationEventRepository,
EscalationEventRepository,
} from '../repository';
import { CreateEscalationPolicyBody, CreateEscalationRuleBody, UpdateEscalationRuleBody } from '../schema';
export class EscalationService {
constructor(
private readonly policies: EscalationPolicyRepository = escalationPolicyRepository,
private readonly rules: EscalationRuleRepository = escalationRuleRepository,
private readonly events: EscalationEventRepository = escalationEventRepository,
private readonly assignments: AssignmentEngine = assignmentEngine,
) {}
/**
* FR-013/FR-014/FR-015: called by the SLA breach sweep (research.md — a direct in-process
* call, not a queued job) for every newly-detected breach. Resolves the applicable policy
* (product-match-or-global), fires one EscalationEvent + scoped re-assignment per matching
* active rule. Records nothing when no policy or no rule matches — the breach itself is
* already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt).
*/
async handleBreach(ticketId: string, triggerType: 'resolution_breach' | 'first_response_breach'): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
const policy = await this.policies.findApplicable(ticket.productId);
if (!policy) return;
const matchingRules = await this.rules.findActiveRules(policy.id, triggerType);
for (const rule of matchingRules) {
await this.fire(ticketId, rule.id, rule.targetNodeId, 'system', `SLA ${triggerType} — rule ${rule.id}`);
}
}
/** FR-016/FR-017: manual escalation to a caller-specified node, rejected if it doesn't exist. */
async escalateManually(
ticketId: string,
targetNodeId: string,
actor: string,
reason: string,
): Promise<EscalationEvent> {
const node = await hierarchyRepository.findById(targetNodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
return this.fire(ticketId, null, targetNodeId, actor, reason);
}
private async fire(
ticketId: string,
ruleId: string | null,
targetNodeId: string,
actor: string,
reason: string,
): Promise<EscalationEvent> {
const event = await this.events.create({
ticketId,
ruleId,
// No existing model persists "which hierarchy node is this ticket currently in" — Assignment
// (007) tracks only agentId, never a hierarchyNodeId — so fromNodeId is honestly left null
// rather than fabricated (data-model.md: "if any").
fromNodeId: null,
toNodeId: targetNodeId,
reason,
triggeredBy: actor,
});
await this.assignments.assignToSpecificNode(ticketId, targetNodeId, actor, reason);
// research.md "SLA_BREACHED/ESCALATION_TRIGGERED are also published, for audit, not for
// logic" — no subscriber consumes this; a durable event-log record only.
await eventBus.publish({
eventId: randomUUID(),
eventName: DomainEventName.ESCALATION_TRIGGERED,
aggregateId: ticketId,
aggregateType: 'Ticket',
timestamp: new Date().toISOString(),
payload: { ticketId, ruleId, targetNodeId, actor, reason },
});
return event;
}
async getHistory(ticketId: string): Promise<EscalationEvent[]> {
return this.events.findAllForTicket(ticketId);
}
async createPolicy(data: CreateEscalationPolicyBody): Promise<EscalationPolicy> {
if (data.productId) {
const product = await productsRepository.findById(data.productId);
if (!product) throw new NotFoundError('Product not found.');
}
return this.policies.create(data);
}
async listPolicies(): Promise<EscalationPolicy[]> {
return this.policies.findAll();
}
async getPolicy(id: string): Promise<EscalationPolicy> {
const policy = await this.policies.findById(id);
if (!policy) throw new NotFoundError('Escalation policy not found.');
return policy;
}
async createRule(policyId: string, data: CreateEscalationRuleBody): Promise<EscalationRule> {
await this.getPolicy(policyId);
const node = await hierarchyRepository.findById(data.targetNodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
return this.rules.create({ ...data, policyId });
}
async updateRule(ruleId: string, data: UpdateEscalationRuleBody): Promise<EscalationRule> {
if (data.targetNodeId) {
const node = await hierarchyRepository.findById(data.targetNodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
}
return this.rules.update(ruleId, data);
}
async deactivateRule(ruleId: string): Promise<EscalationRule> {
return this.rules.deactivate(ruleId);
}
}
export const escalationService = new EscalationService();