feat(PBI-96/T-1058): add ProductDoc + ProductDocLog schema + migration

Voegt twee enums (ProductDocFolder met 8 kern-folders + ProductDocLogType),
een Product-uitbreiding (enabled_doc_folders array met alle 8 als default)
en twee modellen toe (ProductDoc met @@unique(product_id, folder, slug) +
ProductDocLog met denormalized actor_user_id en doc_id nullable + SetNull).

Bestaande producten krijgen de 8-folder-default automatisch via de
ALTER TABLE DEFAULT — geen backfill nodig (zie plan §A.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 11:38:00 +02:00
parent 73cb61d3a2
commit 667be61334
2 changed files with 209 additions and 86 deletions

View file

@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "ProductDocFolder" AS ENUM ('ADR', 'ARCHITECTURE', 'PATTERNS', 'PLANS', 'RUNBOOKS', 'SPECS', 'MANUAL', 'API');
-- CreateEnum
CREATE TYPE "ProductDocLogType" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'FOLDER_ENABLED', 'FOLDER_DISABLED');
-- AlterTable
ALTER TABLE "products" ADD COLUMN "enabled_doc_folders" "ProductDocFolder"[] NOT NULL DEFAULT ARRAY['ADR', 'ARCHITECTURE', 'PATTERNS', 'PLANS', 'RUNBOOKS', 'SPECS', 'MANUAL', 'API']::"ProductDocFolder"[];
-- CreateTable
CREATE TABLE "product_docs" (
"id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"folder" "ProductDocFolder" NOT NULL,
"slug" VARCHAR(80) NOT NULL,
"title" VARCHAR(200) NOT NULL,
"content_md" TEXT NOT NULL,
"status" VARCHAR(20) NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "product_docs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_doc_logs" (
"id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"doc_id" TEXT,
"actor_user_id" TEXT NOT NULL,
"type" "ProductDocLogType" NOT NULL,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "product_doc_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "product_docs_product_id_folder_slug_key" ON "product_docs"("product_id", "folder", "slug");
-- CreateIndex
CREATE INDEX "product_docs_product_id_folder_updated_at_idx" ON "product_docs"("product_id", "folder", "updated_at");
-- CreateIndex
CREATE INDEX "product_docs_product_id_status_idx" ON "product_docs"("product_id", "status");
-- CreateIndex
CREATE INDEX "product_doc_logs_product_id_created_at_idx" ON "product_doc_logs"("product_id", "created_at");
-- CreateIndex
CREATE INDEX "product_doc_logs_doc_id_created_at_idx" ON "product_doc_logs"("doc_id", "created_at");
-- CreateIndex
CREATE INDEX "product_doc_logs_actor_user_id_created_at_idx" ON "product_doc_logs"("actor_user_id", "created_at");
-- AddForeignKey
ALTER TABLE "product_docs" ADD CONSTRAINT "product_docs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_doc_logs" ADD CONSTRAINT "product_doc_logs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_doc_logs" ADD CONSTRAINT "product_doc_logs_doc_id_fkey" FOREIGN KEY ("doc_id") REFERENCES "product_docs"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -138,35 +138,54 @@ enum UserQuestionStatus {
answered
}
enum ProductDocFolder {
ADR
ARCHITECTURE
PATTERNS
PLANS
RUNBOOKS
SPECS
MANUAL
API
}
enum ProductDocLogType {
CREATED
UPDATED
DELETED
FOLDER_ENABLED
FOLDER_DISABLED
}
model User {
id String @id @default(cuid())
username String @unique
email String? @unique
id String @id @default(cuid())
username String @unique
email String? @unique
password_hash String
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
must_reset_password Boolean @default(false)
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
must_reset_password Boolean @default(false)
avatar_data Bytes?
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
settings Json @default("{}")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
settings Json @default("{}")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
api_tokens ApiToken[]
products Product[]
ideas Idea[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
push_subscriptions PushSubscription[]
@@index([active_product_id])
@ -199,32 +218,35 @@ model ApiToken {
}
model Product {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
pr_strategy PrStrategy @default(SPRINT)
preferred_model String?
thinking_budget_default Int?
preferred_permission_mode String?
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
pbis Pbi[]
sprints Sprint[]
stories Story[]
tasks Task[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
ideas Idea[]
idea_products IdeaProduct[]
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
pr_strategy PrStrategy @default(SPRINT)
preferred_model String?
thinking_budget_default Int?
preferred_permission_mode String?
archived Boolean @default(false)
enabled_doc_folders ProductDocFolder[] @default([ADR, ARCHITECTURE, PATTERNS, PLANS, RUNBOOKS, SPECS, MANUAL, API])
created_at DateTime @default(now())
updated_at DateTime @updatedAt
pbis Pbi[]
sprints Sprint[]
stories Story[]
tasks Task[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
ideas Idea[]
idea_products IdeaProduct[]
docs ProductDoc[]
doc_logs ProductDocLog[]
@@unique([user_id, name])
@@unique([user_id, code])
@ -388,47 +410,47 @@ model Task {
}
model ClaudeJob {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
sprint_run_id String?
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
claimed_at DateTime?
started_at DateTime?
finished_at DateTime?
pushed_at DateTime?
verify_result VerifyResult?
model_id String?
input_tokens Int?
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
requested_model String?
requested_thinking_budget Int?
requested_permission_mode String?
actual_thinking_tokens Int?
plan_snapshot String?
base_sha String?
head_sha String?
branch String?
pr_url String?
summary String?
error String?
retry_count Int @default(0)
lease_until DateTime?
task_executions SprintTaskExecution[] @relation("SprintJobExecutions")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
sprint_run_id String?
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
claimed_at DateTime?
started_at DateTime?
finished_at DateTime?
pushed_at DateTime?
verify_result VerifyResult?
model_id String?
input_tokens Int?
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
requested_model String?
requested_thinking_budget Int?
requested_permission_mode String?
actual_thinking_tokens Int?
plan_snapshot String?
base_sha String?
head_sha String?
branch String?
pr_url String?
summary String?
error String?
retry_count Int @default(0)
lease_until DateTime?
task_executions SprintTaskExecution[] @relation("SprintJobExecutions")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id, status])
@@index([task_id, status])
@ -515,6 +537,43 @@ model ProductMember {
@@map("product_members")
}
model ProductDoc {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
folder ProductDocFolder
slug String @db.VarChar(80)
title String @db.VarChar(200)
content_md String @db.Text
status String @db.VarChar(20)
created_by String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
logs ProductDocLog[]
@@unique([product_id, folder, slug])
@@index([product_id, folder, updated_at])
@@index([product_id, status])
@@map("product_docs")
}
model ProductDocLog {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
doc ProductDoc? @relation(fields: [doc_id], references: [id], onDelete: SetNull)
doc_id String?
actor_user_id String
type ProductDocLogType
metadata Json?
created_at DateTime @default(now())
@@index([product_id, created_at])
@@index([doc_id, created_at])
@@index([actor_user_id, created_at])
@@map("product_doc_logs")
}
model Idea {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@ -526,8 +585,8 @@ model Idea {
description String? @db.VarChar(4000)
grill_md String? @db.Text
plan_md String? @db.Text
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
reviewed_at DateTime? // When last reviewed
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
reviewed_at DateTime? // When last reviewed
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
pbi_id String? @unique
status IdeaStatus @default(DRAFT)