Files
support_backend/src/modules/orchestration/escalation/service/escalation.service.ts
T
saqib mirandClaude Sonnet 5 9357f03e1d feat: implement SLA and escalation (008)
Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:

- business-calendars: a luxon-based day-by-day calendar walk
  (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
  weekends, and holidays — replacing the naive createdAt+hours stub FR-004
  explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
  priority, wildcard-or-exact-match, specificity-count + updatedAt
  tiebreak), SLARun creation on the first real publish of the
  long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
  absolute-timestamp shift (no in-memory state, verified across a real
  buildApp() restart), and a repeatable BullMQ breach-detection sweep
  (src/jobs/sla, itself a previously-unregistered stub) that is directly
  callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
  storable, only resolution_breach/first_response_breach evaluated),
  breach-triggered and manual escalation both funnel through one EscalationEvent
  + scoped re-assignment path. AssignmentEngine (007) gains
  assignToSpecificNode — a new, explicitly node-scoped entry point,
  since escalation must never let 007's general resolution re-derive a
  different node than the one a rule or a caller targeted.

Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.

Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:02:05 +05:30

138 lines
5.3 KiB
TypeScript

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();