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>
138 lines
5.3 KiB
TypeScript
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();
|