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 { 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 { 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 { 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 { return this.events.findAllForTicket(ticketId); } async createPolicy(data: CreateEscalationPolicyBody): Promise { 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 { return this.policies.findAll(); } async getPolicy(id: string): Promise { 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 { 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 { 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 { return this.rules.deactivate(ruleId); } } export const escalationService = new EscalationService();