Files
support_backend/prisma/schema.prisma
T
saqib mirandClaude Sonnet 5 1e332143bd build(016-load-concurrency-testing): T001-T002 setup + concurrency-guard migration
Adds autocannon as a devDependency for the load-test tooling (T001), and the
shared schema migration T002 blocks: SLARun.version for optimistic
concurrency, plus two Postgres partial unique indexes
(assignments_one_current_per_ticket, escalation_events_ticket_rule_unique)
guarding against the assignment and escalation races research.md documents.
Applied directly to both the real dev DB and the throwaway test DB.

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

758 lines
25 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)
passwordHash String // bcryptjs hash — never the plaintext password; see
// specs/010-identity-auth/data-model.md
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent?
@@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[]
errorCodeLookups ErrorCodeLookup[]
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[]
investigations Investigation[]
rootCauses RootCause[]
solutions Solution[]
@@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[]
resolution Resolution?
@@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[]
lookups ErrorCodeLookup[]
@@unique([productId, code])
@@map("error_codes")
}
// 015-reporting-dashboards research.md §6: a durable, append-only audit row recording that a
// known-error-code lookup happened — 014-full-observability's own equivalent
// (supporthub_known_error_lookups_total) is a process-lifetime Prometheus counter, unusable for
// a historical "top errors" report. productId is denormalized from errorCode.productId so the
// Product dashboard's range query never needs to join back through ErrorCode just to filter.
model ErrorCodeLookup {
id String @id @default(cuid())
errorCodeId String
errorCode ErrorCode @relation(fields: [errorCodeId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
@@index([productId, createdAt])
@@map("error_code_lookups")
}
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
// Nullable link to the login identity this routing/skills profile belongs to — schema
// capability only, no workflow sets it yet; see specs/010-identity-auth/research.md.
userId String? @unique
user User? @relation(fields: [userId], references: [id])
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])
@@index([agentId, 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?
// 016-load-concurrency-testing: optimistic-concurrency counter, identical convention to
// Ticket.version (003-ticketing) — guards pause/resume/complete/the breach sweep against
// racing each other and silently clobbering this run's state (research.md §2).
version Int @default(0)
@@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")
}
model Investigation {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
investigator String
findings Json
evidence Json?
internalNotes String? // never exposed on a customer-facing read — see
// specs/009-problem-resolution/spec.md FR-003
status String @default("open") // open | complete
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("investigations")
}
model RootCause {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
type String // technical | configuration | external_dependency | business |
// contributing_factor
description String
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("root_causes")
}
model Solution {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
proposed String
approved Boolean @default(false)
createdAt DateTime @default(now())
implementation SolutionImplementation?
verification SolutionVerification?
@@index([problemId, createdAt])
@@map("solutions")
}
model SolutionImplementation {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
notes String?
implementedBy String
implementedAt DateTime @default(now())
@@map("solution_implementations")
}
model SolutionVerification {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
method String // automated | technical_test | customer_confirmation | agent_confirmation
result String // success | failed
evidence Json?
verifiedAt DateTime @default(now())
@@map("solution_verifications")
}
model Resolution {
id String @id @default(cuid())
ticketId String @unique
ticket Ticket @relation(fields: [ticketId], references: [id])
outcome String
resolvedBy String // "ai" | agentId — see specs/009-problem-resolution/data-model.md
resolvedAt DateTime @default(now())
@@map("resolutions")
}