Phase 7 of the roadmap. On a ticket's automatic transition to HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and capability-eligibility lookup directly (never a second matching algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one eligible agent, persisted as a version-row-per-period Assignment plus an append-only AssignmentHistory event log. MANUAL/DIRECT are never auto-selected — only an explicit admin-supplied agentId reaches them. On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through 003's existing state machine. A ticket's "required skill" comes from its most recent AI diagnosis's problemType (005) when one exists, unioned with any matching hierarchy node's skills (006); when neither exists, there's no skill constraint (every active agent eligible), never zero. Found and fixed two real, latent bugs in the shared event-bus infrastructure while building this feature's own tests: (1) EventBus.publish was built on EventEmitter.emit(), which never awaits async listeners, so a caller had no guarantee any subscriber (005's AI-session-ending hook, now also this feature's orchestration hook) had actually finished — rewritten to track subscribers directly and await them via Promise.all, same per-handler error isolation as before. (2) registerDomainEventHandlers() was only called from server.ts's production startup path, never from buildApp() — meaning every integration test in this codebase had zero domain-event subscribers registered at all. Now called (idempotently) from buildApp() itself, since domain-event wiring is synchronous application behavior, not a background-worker concern like the queue. Adds 8 unit tests (each strategy's pure selection/tie-break logic), a dedicated round-robin concurrency test verifying no two concurrent selections collide under real parallel load, and 2 integration test files covering all five user stories. Full regression (every pre-existing 002-006 integration test plus every new 007 test) run together against real Postgres/Redis/MinIO: 124 passed, 9 skipped (005's AI-key-gated tests, unrelated), 0 failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
505 lines
17 KiB
Plaintext
505 lines
17 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[]
|
|
|
|
@@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[]
|
|
|
|
@@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[]
|
|
|
|
@@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
|
|
|
|
@@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")
|
|
}
|