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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bb31e9d641
commit
9357f03e1d
@@ -0,0 +1,147 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "sla_policies" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"productId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"problemTypeId" TEXT,
|
||||
"priority" TEXT,
|
||||
"firstResponseMinutes" INTEGER NOT NULL,
|
||||
"investigationMinutes" INTEGER,
|
||||
"resolutionMinutes" INTEGER NOT NULL,
|
||||
"customerResponseMinutes" INTEGER,
|
||||
"businessCalendarId" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sla_policies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sla_runs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"policyId" TEXT NOT NULL,
|
||||
"firstResponseDueAt" TIMESTAMP(3),
|
||||
"resolutionDueAt" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL,
|
||||
"pausedAt" TIMESTAMP(3),
|
||||
"resumedAt" TIMESTAMP(3),
|
||||
"breachedAt" TIMESTAMP(3),
|
||||
"firstResponseBreachedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "sla_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "business_calendars" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"timezone" TEXT NOT NULL,
|
||||
"workingHours" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "business_calendars_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "holidays" (
|
||||
"id" TEXT NOT NULL,
|
||||
"calendarId" TEXT NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "holidays_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "escalation_policies" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"productId" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "escalation_policies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "escalation_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"policyId" TEXT NOT NULL,
|
||||
"triggerType" TEXT NOT NULL,
|
||||
"condition" JSONB NOT NULL,
|
||||
"targetNodeId" TEXT NOT NULL,
|
||||
"notify" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "escalation_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "escalation_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"ruleId" TEXT,
|
||||
"fromNodeId" TEXT,
|
||||
"toNodeId" TEXT,
|
||||
"reason" TEXT NOT NULL,
|
||||
"triggeredBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "escalation_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sla_policies_productId_categoryId_active_idx" ON "sla_policies"("productId", "categoryId", "active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sla_runs_ticketId_key" ON "sla_runs"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sla_runs_status_resolutionDueAt_idx" ON "sla_runs"("status", "resolutionDueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sla_runs_status_firstResponseDueAt_idx" ON "sla_runs"("status", "firstResponseDueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "holidays_calendarId_date_idx" ON "holidays"("calendarId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "escalation_policies_productId_active_idx" ON "escalation_policies"("productId", "active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "escalation_rules_policyId_triggerType_active_idx" ON "escalation_rules"("policyId", "triggerType", "active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "escalation_events_ticketId_createdAt_idx" ON "escalation_events"("ticketId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_businessCalendarId_fkey" FOREIGN KEY ("businessCalendarId") REFERENCES "business_calendars"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "sla_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "holidays" ADD CONSTRAINT "holidays_calendarId_fkey" FOREIGN KEY ("calendarId") REFERENCES "business_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "escalation_policies" ADD CONSTRAINT "escalation_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "escalation_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_targetNodeId_fkey" FOREIGN KEY ("targetNodeId") REFERENCES "hierarchy_nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "escalation_events" ADD CONSTRAINT "escalation_events_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+138
-3
@@ -43,6 +43,8 @@ model Product {
|
||||
knownIssues KnownIssue[]
|
||||
runbooks Runbook[]
|
||||
aiConfidencePolicies AIConfidencePolicy[]
|
||||
slaPolicies SLAPolicy[]
|
||||
escalationPolicies EscalationPolicy[]
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
@@ -90,9 +92,10 @@ model Category {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
problems Problem[]
|
||||
tickets Ticket[]
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
problems Problem[]
|
||||
tickets Ticket[]
|
||||
slaPolicies SLAPolicy[]
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
@@ -145,6 +148,8 @@ model Ticket {
|
||||
aiSessions AISupportSession[]
|
||||
assignments Assignment[]
|
||||
assignmentHistory AssignmentHistory[]
|
||||
slaRun SLARun?
|
||||
escalationEvents EscalationEvent[]
|
||||
|
||||
@@unique([productId, idempotencyKey])
|
||||
@@index([productId, status])
|
||||
@@ -465,6 +470,8 @@ model HierarchyNode {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
escalationRules EscalationRule[]
|
||||
|
||||
@@index([parentId, order])
|
||||
@@index([active])
|
||||
@@map("hierarchy_nodes")
|
||||
@@ -502,3 +509,131 @@ model AssignmentHistory {
|
||||
@@index([ticketId, createdAt])
|
||||
@@map("assignment_history")
|
||||
}
|
||||
|
||||
model SLAPolicy {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
productId String? // wildcard when null — see data-model.md "Resolution"
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
problemTypeId String? // free-text — no ProblemType table exists in this codebase
|
||||
priority String? // free-text, matches Ticket.priority
|
||||
|
||||
firstResponseMinutes Int
|
||||
investigationMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
|
||||
resolutionMinutes Int
|
||||
customerResponseMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
|
||||
|
||||
businessCalendarId String? // null = 24/7, no exclusions — an explicit policy choice
|
||||
businessCalendar BusinessCalendar? @relation(fields: [businessCalendarId], references: [id])
|
||||
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
slaRuns SLARun[]
|
||||
|
||||
@@index([productId, categoryId, active])
|
||||
@@map("sla_policies")
|
||||
}
|
||||
|
||||
model SLARun {
|
||||
id String @id @default(cuid())
|
||||
ticketId String @unique // one run per ticket — no reopen-cycle support (spec.md Assumptions)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||
policyId String
|
||||
policy SLAPolicy @relation(fields: [policyId], references: [id])
|
||||
|
||||
firstResponseDueAt DateTime?
|
||||
resolutionDueAt DateTime?
|
||||
status String // running | paused | warning | breached | completed
|
||||
|
||||
pausedAt DateTime?
|
||||
resumedAt DateTime?
|
||||
|
||||
breachedAt DateTime?
|
||||
// Additive refinement beyond doc06 (research.md/data-model.md): records a first-response
|
||||
// breach separately from the resolution-timer breach status above, and doubles as the
|
||||
// idempotency guard for the breach-detection sweep (never re-fires on the same run).
|
||||
firstResponseBreachedAt DateTime?
|
||||
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([status, resolutionDueAt])
|
||||
@@index([status, firstResponseDueAt])
|
||||
@@map("sla_runs")
|
||||
}
|
||||
|
||||
model BusinessCalendar {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
timezone String // IANA zone name, e.g. "America/New_York"
|
||||
workingHours Json // { mon?: {start,end}, tue?: ..., ... } — see research.md
|
||||
|
||||
holidays Holiday[]
|
||||
policies SLAPolicy[]
|
||||
|
||||
@@map("business_calendars")
|
||||
}
|
||||
|
||||
model Holiday {
|
||||
id String @id @default(cuid())
|
||||
calendarId String
|
||||
calendar BusinessCalendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
|
||||
date DateTime // compared by calendar date only, in the calendar's own timezone
|
||||
description String?
|
||||
|
||||
@@index([calendarId, date])
|
||||
@@map("holidays")
|
||||
}
|
||||
|
||||
model EscalationPolicy {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
productId String? // wildcard (global) when null — see research.md "Escalation policy resolution"
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
active Boolean @default(true)
|
||||
|
||||
rules EscalationRule[]
|
||||
|
||||
@@index([productId, active])
|
||||
@@map("escalation_policies")
|
||||
}
|
||||
|
||||
model EscalationRule {
|
||||
id String @id @default(cuid())
|
||||
policyId String
|
||||
policy EscalationPolicy @relation(fields: [policyId], references: [id])
|
||||
|
||||
triggerType String // one of doc05 §6's 10 values; only resolution_breach/first_response_breach
|
||||
// are ever evaluated by this feature — the other 8 are valid, stored, inert config
|
||||
// (research.md)
|
||||
condition Json // stored, not evaluated, by this feature (research.md)
|
||||
|
||||
targetNodeId String
|
||||
targetNode HierarchyNode @relation(fields: [targetNodeId], references: [id])
|
||||
|
||||
notify Json // who/how to notify — stored and returned only, no delivery mechanism exists
|
||||
active Boolean @default(true)
|
||||
|
||||
@@index([policyId, triggerType, active])
|
||||
@@map("escalation_rules")
|
||||
}
|
||||
|
||||
model EscalationEvent {
|
||||
id String @id @default(cuid())
|
||||
ticketId String
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||
|
||||
ruleId String? // null for a manual escalation or a breach with no matching rule
|
||||
fromNodeId String?
|
||||
toNodeId String?
|
||||
|
||||
reason String
|
||||
triggeredBy String // system | <agentId> | <adminId>
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([ticketId, createdAt])
|
||||
@@map("escalation_events")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user