Files
support_backend/prisma/schema.prisma
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

640 lines
21 KiB
Plaintext

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum UserRole {
ADMIN
AGENT
CUSTOMER
}
model User {
id String @id @default(uuid())
email String @unique
name String
role UserRole @default(CUSTOMER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Product {
id String @id @default(cuid())
externalProductId String @unique // reference into SaaS, not authoritative — see
// .specify/memory/constitution.md Principle I
name String
supportEnabled Boolean @default(true)
status String @default("active") // active | suspended | deprecated
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories Category[]
integration ProductIntegration?
problems Problem[]
tickets Ticket[]
knowledgeEntries KnowledgeEntry[]
errorCodes ErrorCode[]
knownIssues KnownIssue[]
runbooks Runbook[]
aiConfidencePolicies AIConfidencePolicy[]
slaPolicies SLAPolicy[]
escalationPolicies EscalationPolicy[]
@@map("products")
}
model ProductIntegration {
id String @id @default(cuid())
productId String @unique
product Product @relation(fields: [productId], references: [id])
// AES-256-GCM-encrypted secret (ciphertext, not plaintext) — see
// specs/002-saas-integration/research.md "Credential storage"
credentialRef String
previousCredentialRef String?
previousCredentialExpiresAt DateTime?
authMechanism String @default("signed_token") // free-text — not an enum,
// so a future integration can use oauth2_client_credentials or mtls without a migration
allowedScope Json // { tenantIds?: string[], allowAnyTenant?: boolean }
rateLimitPerMinute Int @default(60)
rateLimitPerUserPerMinute Int @default(20)
status String @default("active") // active | suspended
rotatedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@map("product_integrations")
}
model CustomerReference {
id String @id @default(cuid())
externalUserId String
externalTenantId String
createdAt DateTime @default(now())
tickets Ticket[]
@@unique([externalUserId, externalTenantId])
@@map("customer_references")
}
model Category {
id String @id @default(uuid())
productId String
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
problems Problem[]
tickets Ticket[]
slaPolicies SLAPolicy[]
@@map("categories")
}
model Problem {
id String @id @default(cuid())
statement String
symptoms String
impact String?
productId String
categoryId String?
severity String
customerImpact String?
businessImpact String?
environment String?
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
tickets Ticket[]
@@map("problems")
}
model Ticket {
id String @id @default(cuid())
code String @unique // <PRODUCT_CODE>-<YEAR>-<SEQUENCE> — see
// specs/003-ticketing/research.md "Ticket code format"
productId String
problemId String
customerId String
externalUserId String // denormalized copy of CustomerReference's field, for query
externalTenantId String // convenience without a join — see data-model.md
status String @default("NEW") // one of the 12 lifecycle states — see
// specs/003-ticketing/research.md "Ticket lifecycle state machine"
priority String
severity String
categoryId String?
idempotencyKey String?
version Int @default(1) // optimistic concurrency — see research.md
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id])
problem Problem @relation(fields: [problemId], references: [id])
customer CustomerReference @relation(fields: [customerId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
messages TicketMessage[]
attachments TicketAttachment[]
aiSessions AISupportSession[]
assignments Assignment[]
assignmentHistory AssignmentHistory[]
slaRun SLARun?
escalationEvents EscalationEvent[]
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@index([externalTenantId, externalUserId])
@@map("tickets")
}
model TicketMessage {
id String @id @default(cuid())
ticketId String
type String // CUSTOMER_MESSAGE | AI_MESSAGE | AGENT_MESSAGE | INTERNAL_NOTE |
// SYSTEM_EVENT | INVESTIGATION_NOTE | SOLUTION_NOTE
authorRef String // agentId, "ai", "system", or externalUserId — never a local FK
body String
visibleToCustomer Boolean // set from the type->visibility map at write time — see
// specs/003-ticketing/research.md "Message type -> visibility mapping"
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id])
@@index([ticketId, visibleToCustomer, createdAt])
@@map("ticket_messages")
}
model TicketAttachment {
id String @id @default(cuid())
ticketId String
storageKey String // S3/MinIO object key — never the file itself
fileName String
mimeType String
sizeBytes Int
scanStatus String @default("pending") // pending | clean | infected | rejected
uploadedBy String // agentId or externalUserId — same non-FK convention as authorRef
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id])
@@map("ticket_attachments")
}
model KnowledgeEntry {
id String @id @default(cuid())
code String // KB-<PRODUCT>-<SEQ>, e.g. KB-DQ-102 — shared across versions,
// logical identifier is (code, version), NOT code alone — see
// specs/004-product-knowledge/research.md "Versioning mechanism"
version Int @default(1)
isCurrentVersion Boolean @default(true)
productId String
feature String?
type String // known_issue | faq | resolution_procedure | operations
problem String?
symptoms String?
errorCode String?
cause String?
recommendedSolution String?
verificationSteps String?
escalationGuidance String?
status String @default("draft") // draft | published | unpublished
effectiveDate DateTime?
categoryScope String[]
validationStatus String @default("unvalidated") // unvalidated | validated
owner String?
lastReview DateTime?
source String?
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
@@unique([code, version])
@@index([productId, isCurrentVersion, status, effectiveDate])
@@map("knowledge_entries")
}
model ErrorCode {
id String @id @default(cuid())
code String // e.g. LAYOUT_PARSE_042
productId String
description String
product Product @relation(fields: [productId], references: [id])
knownIssues KnownIssue[]
@@unique([productId, code])
@@map("error_codes")
}
model KnownIssue {
id String @id @default(cuid())
productId String
errorCodeId String?
description String
status String @default("open")
product Product @relation(fields: [productId], references: [id])
errorCode ErrorCode? @relation(fields: [errorCodeId], references: [id])
@@map("known_issues")
}
model Runbook {
id String @id @default(cuid())
key String // e.g. PDF_HTML_CONVERSION_FAILURE — shared across versions, logical
// identifier is (key, productId, version), NOT key alone — same convention as KnowledgeEntry
version Int @default(1)
isCurrentVersion Boolean @default(true)
productId String
steps Json // ordered array — order preserved exactly as authored
active Boolean @default(true)
product Product @relation(fields: [productId], references: [id])
@@unique([key, productId, version])
@@index([productId, key, isCurrentVersion, active])
@@map("runbooks")
}
model AuditLog {
id String @id @default(cuid())
actor String // ProductIntegration id, agent id, "system", "ai", etc. — never a local
// User foreign key; see specs/002-saas-integration/research.md "Aligning AuditLog"
actorType String // customer | agent | admin | system | ai
action String
entityType String
entityId String
oldValue Json?
newValue Json?
reason String?
metadata Json?
createdAt DateTime @default(now())
@@map("audit_logs")
}
model AISupportSession {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
status String // analyzing | troubleshooting | verifying | resolved | escalated |
// ended_by_agent — mirrored onto Ticket.status through the existing 003 state machine, see
// specs/005-ai-support/research.md "AISupportSession.status drives Ticket.status"
activeRunbookKey String?
currentStepIndex Int?
clarifyingQuestionsAsked Int @default(0)
toolCallCount Int @default(0)
startedAt DateTime @default(now())
endedAt DateTime?
diagnoses AIDiagnosis[]
interactions AIInteraction[]
actions AIAction[]
knowledgeRefs AIKnowledgeReference[]
@@index([ticketId, status])
@@map("ai_support_sessions")
}
model AIDiagnosis {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
product String
feature String?
problemType String
severity String
confidence Float
possibleCauses String[]
createdAt DateTime @default(now())
@@index([sessionId])
@@map("ai_diagnoses")
}
model AIInteraction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
role String // customer | ai
content String
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
@@map("ai_interactions")
}
model AIAction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
toolName String
input Json
riskLevel String // low | medium | high — copied from the registry at evaluation time
evaluationOutcome String // approved | pending_approval | refused
refusalReason String?
approvedBy String? // system-policy | agentId | null while pending_approval
createdAt DateTime @default(now())
result AIActionResult?
@@index([sessionId, createdAt])
@@map("ai_actions")
}
model AIActionResult {
id String @id @default(cuid())
actionId String @unique
action AIAction @relation(fields: [actionId], references: [id])
output Json
status String // success | failed
createdAt DateTime @default(now())
@@map("ai_action_results")
}
model AIKnowledgeReference {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
knowledgeId String // KnowledgeEntry.id — resolved through ai-support/knowledge's public
// index.ts, not a cross-module DB-level FK (Constitution Principle III)
relevanceScore Float?
createdAt DateTime @default(now())
@@index([sessionId])
@@map("ai_knowledge_references")
}
model AIConfidencePolicy {
id String @id @default(cuid())
productId String? // null = system-wide default row
product Product? @relation(fields: [productId], references: [id])
categoryId String? // null = applies to every category of productId
highThreshold Float
lowThreshold Float
maxClarifyingQuestions Int
updatedAt DateTime @updatedAt
@@unique([productId, categoryId])
@@map("ai_confidence_policies")
}
model Team {
id String @id @default(cuid())
name String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agents Agent[]
hierarchyNodes HierarchyNode[]
@@map("teams")
}
model Agent {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id])
name String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
skills AgentSkill[]
availability AgentAvailability?
assignments Assignment[]
@@index([teamId, active])
@@map("agents")
}
model AgentSkill {
id String @id @default(cuid())
agentId String
agent Agent @relation(fields: [agentId], references: [id])
skillTag String
level Int // proficiency, used by a future SKILL_BASED assignment strategy — not
// interpreted by this feature
@@unique([agentId, skillTag])
@@map("agent_skills")
}
model AgentAvailability {
id String @id @default(cuid())
agentId String @unique
agent Agent @relation(fields: [agentId], references: [id])
status String // available | busy | away | offline — validated at the schema layer
workingHours Json // per business calendar — opaque to this feature
currentLoad Int @default(0)
updatedAt DateTime @updatedAt
// Last-write-wins on purpose — see specs/006-support-organization/research.md "Availability
// concurrency"; no expectedVersion field here, unlike Ticket.status/KnowledgeEntry.version.
@@map("agent_availability")
}
model HierarchyNode {
id String @id @default(cuid())
name String
parentId String?
parent HierarchyNode? @relation("HierarchyTree", fields: [parentId], references: [id])
children HierarchyNode[] @relation("HierarchyTree")
order Int
teamId String?
team Team? @relation(fields: [teamId], references: [id])
skills String[]
productScope String[] // external product ids; empty = matches every product
categoryScope String[] // free text; empty = matches every category
priorityScope String[] // free text; empty = matches every priority
assignmentStrategy String // free-text reference — no real strategy table exists yet (Phase 7)
slaPolicyId String? // free-text reference — no SlaPolicy table exists yet (Phase 8)
escalationPolicyId String? // free-text reference — no EscalationPolicy table exists yet
entryConditions Json? // opaque rule expression — stored, not evaluated, by this feature
exitConditions Json? // opaque rule expression — stored, not evaluated, by this feature
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
escalationRules EscalationRule[]
@@index([parentId, order])
@@index([active])
@@map("hierarchy_nodes")
}
model Assignment {
id String @id @default(cuid())
ticketId String // not unique — one row per assignment period, see
// specs/007-orchestration-assignment/research.md
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String
agent Agent @relation(fields: [agentId], references: [id])
strategy String // ROUND_ROBIN | LEAST_LOADED | SKILL_BASED | MANUAL | DIRECT
reason String?
isCurrent Boolean @default(true)
assignedAt DateTime @default(now())
unassignedAt DateTime?
@@index([ticketId, isCurrent])
@@map("assignments")
}
model AssignmentHistory {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String? // null for a "no eligible agent" outcome — FR-008
action String // assigned | reassigned | unassigned
strategy String
reason String?
actor String // system | agentId | adminId
createdAt DateTime @default(now())
@@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")
}