Files
support_backend/prisma/schema.prisma
T
saqib mirandClaude Sonnet 5 e33d86081f feat: implement product knowledge management & retrieval (004)
Implements 26 of 28 tasks from specs/004-product-knowledge/tasks.md
across all three user stories -- Phase 3 of the roadmap. First
feature to populate src/modules/ai-support/ (doc 07 places
`knowledge` there; only that submodule is built, matching this
codebase's convention of not pre-building unneeded submodules).

Schema (prisma/schema.prisma + migration):
- KnowledgeEntry, ErrorCode, KnownIssue, Runbook per docs/06,
  refining its conceptual flat `version` field into an explicit
  version-history mechanism: each edit inserts a new row
  (isCurrentVersion flag, compound unique on (code, version) /
  (key, productId, version)) instead of overwriting in place -- the
  only way "prior versions remain retrievable" (FR-004/FR-009) is
  actually true rather than aspirational.

User Story 1 -- knowledge entry authoring/publish/version (P1, MVP):
- draft -> published -> unpublished lifecycle; publish only takes
  effect from its effectiveDate.
- Editing uses the same conditional-update-then-insert optimistic
  concurrency pattern as 003-ticketing's Ticket.version (409 on a
  stale expectedVersion).
- Full version history readable via GET .../versions.

User Story 2 -- error codes, known issues, runbooks (P2):
- ErrorCode + KnownIssue with direct lookup-by-error-code.
- Runbook steps stored as an ordered JSON array, preserved exactly;
  same version-on-edit mechanism as knowledge entries; inactive
  runbooks are indistinguishable from nonexistent ones on lookup.

User Story 3 -- filtered retrieval (P3):
- GET /knowledge/retrieve: product-scoped, excludes draft/
  unpublished/not-yet-effective entries, validated entries ranked
  ahead of unvalidated. Deliberately NOT semantic/vector search --
  doc 11 gap B1 explicitly defers embedding-model choice to the
  future AI-support feature; this is real, usable structured
  filtering a semantic layer can sit in front of later.

Found and fixed one real bug before it reached tests: the retrieval
endpoint initially queried by the raw external product id instead of
resolving it to the internal Product.id first (every other endpoint
in this feature does that resolution) -- would have silently
returned zero results for every caller. Fixed with a lenient
tryResolveProductId (empty array, not 404, for an unregistered
product -- matches the "no matches, never an error" contract).

Deliberately skipped (not forgotten, see checklist notes): the two
planned mock-repository unit-test tasks (T004, T019) -- unlike
003-ticketing's state machine, this feature has no pure-logic
surface to isolate from Prisma; coverage comes entirely from
integration tests instead.

All 13 integration test files in the repo (36 tests, spanning this
feature and every prior one) verified passing together against a
real Postgres/Redis/MinIO -- no regressions. Full quality gate
(typecheck/lint/format/architecture/unit tests) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:55:18 +05:30

276 lines
8.9 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[]
@@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[]
@@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")
}