diff --git a/apps/server/prisma/migrations/20260703000000_v22_partitioned_domain_schema/migration.sql b/apps/server/prisma/migrations/20260703000000_v22_partitioned_domain_schema/migration.sql new file mode 100644 index 0000000..930ce48 --- /dev/null +++ b/apps/server/prisma/migrations/20260703000000_v22_partitioned_domain_schema/migration.sql @@ -0,0 +1,414 @@ +-- V2.2 domain schema foundation. +-- High-growth business tables are partitioned from the start so primary, +-- unique, and foreign key shapes do not need to be redesigned after data grows. + +-- Preserve the early prototype requirement table before replacing it with the +-- V2.2 partitioned requirements pool table. +ALTER TABLE "requirements" RENAME TO "requirements_legacy"; +ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_pkey" TO "requirements_legacy_pkey"; +ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_product_id_fkey" TO "requirements_legacy_product_id_fkey"; +ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_creator_id_fkey" TO "requirements_legacy_creator_id_fkey"; + +ALTER TABLE "versions" ADD COLUMN "project_id" TEXT; +ALTER TABLE "versions" ADD CONSTRAINT "versions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX "versions_product_id_project_id_idx" ON "versions"("product_id", "project_id"); + +CREATE TABLE "task_categories" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT, + "group" TEXT NOT NULL, + "is_system" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_categories_pkey" PRIMARY KEY ("id"), + CONSTRAINT "task_categories_group_name_key" UNIQUE ("group", "name") +); + +CREATE UNIQUE INDEX "task_categories_group_code_key" ON "task_categories"("group", "code") WHERE "code" IS NOT NULL; + +CREATE TABLE "requirements" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "project_id" TEXT, + "version_id" TEXT, + "code" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'draft', + "priority" INTEGER NOT NULL DEFAULT 0, + "type" TEXT, + "source_type" TEXT, + "source_target" TEXT, + "platform" TEXT, + "creator_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "requirements_pkey" PRIMARY KEY ("id", "product_id"), + CONSTRAINT "requirements_product_id_code_key" UNIQUE ("product_id", "code"), + CONSTRAINT "requirements_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "requirements_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "requirements_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "requirements_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE +) PARTITION BY HASH ("product_id"); + +CREATE TABLE "requirements_p00" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 0); +CREATE TABLE "requirements_p01" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 1); +CREATE TABLE "requirements_p02" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 2); +CREATE TABLE "requirements_p03" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 3); +CREATE TABLE "requirements_p04" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 4); +CREATE TABLE "requirements_p05" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 5); +CREATE TABLE "requirements_p06" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 6); +CREATE TABLE "requirements_p07" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 7); +CREATE TABLE "requirements_p08" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 8); +CREATE TABLE "requirements_p09" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 9); +CREATE TABLE "requirements_p10" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 10); +CREATE TABLE "requirements_p11" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 11); +CREATE TABLE "requirements_p12" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 12); +CREATE TABLE "requirements_p13" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 13); +CREATE TABLE "requirements_p14" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 14); +CREATE TABLE "requirements_p15" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 15); + +CREATE INDEX "requirements_product_project_status_created_at_idx" ON "requirements"("product_id", "project_id", "status", "created_at" DESC); +CREATE INDEX "requirements_version_status_created_at_idx" ON "requirements"("version_id", "status", "created_at" DESC); +CREATE INDEX "requirements_creator_created_at_idx" ON "requirements"("creator_id", "created_at" DESC); + +CREATE TABLE "version_plans" ( + "id" TEXT NOT NULL, + "version_id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "project_id" TEXT, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "owner_id" TEXT, + "expected_start_at" TIMESTAMP(3), + "expected_end_at" TIMESTAMP(3), + "actual_start_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "result_url" TEXT, + "requirement_coverage" JSONB NOT NULL DEFAULT '[]', + "logs" JSONB NOT NULL DEFAULT '[]', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "version_plans_pkey" PRIMARY KEY ("id"), + CONSTRAINT "version_plans_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "version_plans_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "version_plans_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "version_plans_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE INDEX "version_plans_version_type_status_idx" ON "version_plans"("version_id", "type", "status"); +CREATE INDEX "version_plans_owner_status_end_idx" ON "version_plans"("owner_id", "status", "expected_end_at") WHERE "status" <> 'completed'; + +CREATE TABLE "dev_tasks" ( + "id" TEXT NOT NULL, + "version_id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "requirement_id" TEXT, + "requirement_product_id" TEXT, + "category_id" TEXT, + "code" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'todo', + "priority" INTEGER NOT NULL DEFAULT 0, + "assignee_id" TEXT, + "creator_id" TEXT, + "is_blocked" BOOLEAN NOT NULL DEFAULT false, + "block_reason" TEXT, + "expected_start_at" TIMESTAMP(3), + "expected_end_at" TIMESTAMP(3), + "start_date" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "estimate_hours" DOUBLE PRECISION, + "ai_estimate_hours" DOUBLE PRECISION, + "references" JSONB NOT NULL DEFAULT '[]', + "ai_draft" BOOLEAN NOT NULL DEFAULT false, + "ai_draft_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dev_tasks_pkey" PRIMARY KEY ("id", "version_id"), + CONSTRAINT "dev_tasks_version_id_code_key" UNIQUE ("version_id", "code"), + CONSTRAINT "dev_tasks_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "dev_tasks_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "dev_tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "dev_tasks_requirement_fkey" FOREIGN KEY ("requirement_id", "requirement_product_id") REFERENCES "requirements"("id", "product_id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "dev_tasks_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "task_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "dev_tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "dev_tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE +) PARTITION BY HASH ("version_id"); + +CREATE TABLE "dev_tasks_p00" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 0); +CREATE TABLE "dev_tasks_p01" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 1); +CREATE TABLE "dev_tasks_p02" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 2); +CREATE TABLE "dev_tasks_p03" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 3); +CREATE TABLE "dev_tasks_p04" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 4); +CREATE TABLE "dev_tasks_p05" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 5); +CREATE TABLE "dev_tasks_p06" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 6); +CREATE TABLE "dev_tasks_p07" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 7); +CREATE TABLE "dev_tasks_p08" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 8); +CREATE TABLE "dev_tasks_p09" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 9); +CREATE TABLE "dev_tasks_p10" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 10); +CREATE TABLE "dev_tasks_p11" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 11); +CREATE TABLE "dev_tasks_p12" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 12); +CREATE TABLE "dev_tasks_p13" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 13); +CREATE TABLE "dev_tasks_p14" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 14); +CREATE TABLE "dev_tasks_p15" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 15); + +CREATE INDEX "dev_tasks_version_status_updated_at_idx" ON "dev_tasks"("version_id", "status", "updated_at" DESC); +CREATE INDEX "dev_tasks_requirement_id_idx" ON "dev_tasks"("requirement_id"); +CREATE INDEX "dev_tasks_assignee_unfinished_idx" ON "dev_tasks"("assignee_id", "status", "updated_at" DESC) WHERE "status" <> 'submitted'; + +CREATE TABLE "test_cases" ( + "id" TEXT NOT NULL, + "version_id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "requirement_id" TEXT, + "requirement_product_id" TEXT, + "category_id" TEXT, + "code" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'pending', + "round_no" INTEGER NOT NULL DEFAULT 1, + "priority" INTEGER NOT NULL DEFAULT 0, + "assignee_id" TEXT, + "creator_id" TEXT, + "planned_test_at" TIMESTAMP(3), + "planned_end_at" TIMESTAMP(3), + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "estimate_hours" DOUBLE PRECISION, + "ai_estimate_hours" DOUBLE PRECISION, + "references" JSONB NOT NULL DEFAULT '[]', + "ai_draft" BOOLEAN NOT NULL DEFAULT false, + "ai_draft_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "test_cases_pkey" PRIMARY KEY ("id", "version_id"), + CONSTRAINT "test_cases_version_id_code_key" UNIQUE ("version_id", "code"), + CONSTRAINT "test_cases_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "test_cases_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "test_cases_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "test_cases_requirement_fkey" FOREIGN KEY ("requirement_id", "requirement_product_id") REFERENCES "requirements"("id", "product_id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "test_cases_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "task_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "test_cases_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "test_cases_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE +) PARTITION BY HASH ("version_id"); + +CREATE TABLE "test_cases_p00" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 0); +CREATE TABLE "test_cases_p01" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 1); +CREATE TABLE "test_cases_p02" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 2); +CREATE TABLE "test_cases_p03" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 3); +CREATE TABLE "test_cases_p04" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 4); +CREATE TABLE "test_cases_p05" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 5); +CREATE TABLE "test_cases_p06" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 6); +CREATE TABLE "test_cases_p07" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 7); +CREATE TABLE "test_cases_p08" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 8); +CREATE TABLE "test_cases_p09" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 9); +CREATE TABLE "test_cases_p10" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 10); +CREATE TABLE "test_cases_p11" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 11); +CREATE TABLE "test_cases_p12" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 12); +CREATE TABLE "test_cases_p13" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 13); +CREATE TABLE "test_cases_p14" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 14); +CREATE TABLE "test_cases_p15" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 15); + +CREATE INDEX "test_cases_version_round_status_updated_at_idx" ON "test_cases"("version_id", "round_no", "status", "updated_at" DESC); +CREATE INDEX "test_cases_requirement_id_idx" ON "test_cases"("requirement_id"); +CREATE INDEX "test_cases_assignee_unfinished_idx" ON "test_cases"("assignee_id", "status", "updated_at" DESC) WHERE "status" NOT IN ('passed', 'failed', 'blocked'); + +CREATE TABLE "bugs" ( + "id" TEXT NOT NULL, + "version_id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "test_case_id" TEXT, + "test_case_version_id" TEXT, + "code" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'open', + "severity" TEXT NOT NULL DEFAULT 'normal', + "priority" INTEGER NOT NULL DEFAULT 0, + "assignee_id" TEXT, + "reporter_id" TEXT, + "planned_fix_at" TIMESTAMP(3), + "resolved_at" TIMESTAMP(3), + "closed_at" TIMESTAMP(3), + "resolution" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bugs_pkey" PRIMARY KEY ("id", "version_id"), + CONSTRAINT "bugs_version_id_code_key" UNIQUE ("version_id", "code"), + CONSTRAINT "bugs_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "bugs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "bugs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "bugs_test_case_fkey" FOREIGN KEY ("test_case_id", "test_case_version_id") REFERENCES "test_cases"("id", "version_id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "bugs_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "bugs_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE +) PARTITION BY HASH ("version_id"); + +CREATE TABLE "bugs_p00" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 0); +CREATE TABLE "bugs_p01" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 1); +CREATE TABLE "bugs_p02" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 2); +CREATE TABLE "bugs_p03" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 3); +CREATE TABLE "bugs_p04" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 4); +CREATE TABLE "bugs_p05" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 5); +CREATE TABLE "bugs_p06" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 6); +CREATE TABLE "bugs_p07" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 7); +CREATE TABLE "bugs_p08" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 8); +CREATE TABLE "bugs_p09" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 9); +CREATE TABLE "bugs_p10" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 10); +CREATE TABLE "bugs_p11" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 11); +CREATE TABLE "bugs_p12" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 12); +CREATE TABLE "bugs_p13" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 13); +CREATE TABLE "bugs_p14" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 14); +CREATE TABLE "bugs_p15" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 15); + +CREATE INDEX "bugs_version_status_severity_updated_at_idx" ON "bugs"("version_id", "status", "severity", "updated_at" DESC); +CREATE INDEX "bugs_test_case_id_idx" ON "bugs"("test_case_id"); +CREATE INDEX "bugs_assignee_open_idx" ON "bugs"("assignee_id", "status", "updated_at" DESC) WHERE "status" IN ('open', 'fixing', 'fixed', 'verifying'); + +CREATE TABLE "work_activities" ( + "id" TEXT NOT NULL, + "version_id" TEXT, + "product_id" TEXT, + "project_id" TEXT, + "actor_id" TEXT, + "actor_name" TEXT NOT NULL DEFAULT '', + "source_type" TEXT NOT NULL, + "source_id" TEXT NOT NULL, + "source_version_id" TEXT, + "action" TEXT NOT NULL, + "title" TEXT NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "occurred_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "work_activities_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "work_activities_default" PARTITION OF "work_activities" DEFAULT; +CREATE INDEX "work_activities_version_created_at_idx" ON "work_activities"("version_id", "created_at" DESC); +CREATE INDEX "work_activities_actor_created_at_idx" ON "work_activities"("actor_id", "created_at" DESC); + +CREATE TABLE "task_worklogs" ( + "id" TEXT NOT NULL, + "version_id" TEXT, + "product_id" TEXT, + "project_id" TEXT, + "user_id" TEXT, + "source_type" TEXT NOT NULL, + "source_id" TEXT NOT NULL, + "source_version_id" TEXT, + "work_date" DATE NOT NULL, + "hours" DOUBLE PRECISION NOT NULL DEFAULT 0, + "content" TEXT NOT NULL DEFAULT '', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_worklogs_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "task_worklogs_default" PARTITION OF "task_worklogs" DEFAULT; +CREATE INDEX "task_worklogs_user_date_idx" ON "task_worklogs"("user_id", "work_date" DESC); +CREATE INDEX "task_worklogs_version_date_idx" ON "task_worklogs"("version_id", "work_date" DESC); + +CREATE TABLE "overtime_records" ( + "id" TEXT NOT NULL, + "product_id" TEXT, + "project_id" TEXT, + "version_id" TEXT, + "user_id" TEXT, + "reason" TEXT NOT NULL DEFAULT '', + "start_at" TIMESTAMP(3) NOT NULL, + "end_at" TIMESTAMP(3) NOT NULL, + "hours" DOUBLE PRECISION NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "overtime_records_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "overtime_records_default" PARTITION OF "overtime_records" DEFAULT; +CREATE INDEX "overtime_records_user_created_at_idx" ON "overtime_records"("user_id", "created_at" DESC); +CREATE INDEX "overtime_records_project_created_at_idx" ON "overtime_records"("project_id", "created_at" DESC); + +CREATE TABLE "xiaobao_risk_snapshots" ( + "id" TEXT NOT NULL, + "version_id" TEXT NOT NULL, + "snapshot_date" DATE NOT NULL, + "risk_level" TEXT NOT NULL, + "risk_score" INTEGER NOT NULL DEFAULT 0, + "risk_signature" TEXT NOT NULL, + "snapshot" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "xiaobao_risk_snapshots_pkey" PRIMARY KEY ("id", "created_at"), + CONSTRAINT "xiaobao_risk_snapshots_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "xiaobao_risk_snapshots_default" PARTITION OF "xiaobao_risk_snapshots" DEFAULT; +CREATE INDEX "xiaobao_risk_snapshots_version_created_at_idx" ON "xiaobao_risk_snapshots"("version_id", "created_at" DESC); +CREATE INDEX "xiaobao_risk_snapshots_date_idx" ON "xiaobao_risk_snapshots"("snapshot_date" DESC); + +CREATE TABLE "xiaobao_risk_insights" ( + "id" TEXT NOT NULL, + "version_id" TEXT NOT NULL, + "risk_signature" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'generated', + "insight" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "xiaobao_risk_insights_pkey" PRIMARY KEY ("id", "created_at"), + CONSTRAINT "xiaobao_risk_insights_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "xiaobao_risk_insights_default" PARTITION OF "xiaobao_risk_insights" DEFAULT; +CREATE INDEX "xiaobao_risk_insights_version_signature_idx" ON "xiaobao_risk_insights"("version_id", "risk_signature", "created_at" DESC); + +CREATE TABLE "ai_logs" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "status" TEXT NOT NULL, + "prompt_tokens" INTEGER NOT NULL DEFAULT 0, + "completion_tokens" INTEGER NOT NULL DEFAULT 0, + "duration_ms" INTEGER NOT NULL DEFAULT 0, + "error_message" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ai_logs_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "ai_logs_default" PARTITION OF "ai_logs" DEFAULT; +CREATE INDEX "ai_logs_operation_created_at_idx" ON "ai_logs"("operation", "created_at" DESC); +CREATE INDEX "ai_logs_status_created_at_idx" ON "ai_logs"("status", "created_at" DESC); + +CREATE TABLE "xiaobao_risk_summaries" ( + "version_id" TEXT NOT NULL, + "risk_level" TEXT NOT NULL, + "risk_score" INTEGER NOT NULL DEFAULT 0, + "confidence" INTEGER NOT NULL DEFAULT 0, + "forecast_release_date" TIMESTAMP(3), + "risk_signature" TEXT NOT NULL, + "summary" JSONB NOT NULL, + "dirty" BOOLEAN NOT NULL DEFAULT false, + "recomputed_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "xiaobao_risk_summaries_pkey" PRIMARY KEY ("version_id"), + CONSTRAINT "xiaobao_risk_summaries_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index a7c1008..c9060f0 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -49,6 +49,7 @@ model Project { product Product @relation(fields: [productId], references: [id]) sprints Sprint[] tasks Task[] + versions Version[] members ProjectMember[] @@map("projects") @@ -72,6 +73,7 @@ model Sprint { model Version { id String @id @default(cuid()) productId String @map("product_id") + projectId String? @map("project_id") name String description String @default("") releaseDate DateTime? @map("release_date") @@ -79,25 +81,35 @@ model Version { updatedAt DateTime @updatedAt @map("updated_at") product Product @relation(fields: [productId], references: [id]) + project Project? @relation(fields: [projectId], references: [id]) tasks Task[] @@map("versions") } model Requirement { - id String @id @default(cuid()) + id String @default(cuid()) productId String @map("product_id") + projectId String? @map("project_id") + versionId String? @map("version_id") + code String title String description String @default("") status String @default("draft") priority Int @default(0) - creatorId String @map("creator_id") + type String? + sourceType String? @map("source_type") + sourceTarget String? @map("source_target") + platform String? + creatorId String? @map("creator_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") product Product @relation(fields: [productId], references: [id]) - creator User @relation(fields: [creatorId], references: [id]) + creator User? @relation(fields: [creatorId], references: [id]) + @@id([id, productId]) + @@unique([productId, code]) @@map("requirements") } @@ -168,6 +180,250 @@ model ProjectMember { @@map("project_members") } +model TaskCategory { + id String @id @default(cuid()) + name String + code String? + group String + isSystem Boolean @default(false) @map("is_system") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([group, name]) + @@map("task_categories") +} + +model VersionPlan { + id String @id @default(cuid()) + versionId String @map("version_id") + productId String @map("product_id") + projectId String? @map("project_id") + type String + title String + status String @default("pending") + ownerId String? @map("owner_id") + expectedStartAt DateTime? @map("expected_start_at") + expectedEndAt DateTime? @map("expected_end_at") + actualStartAt DateTime? @map("actual_start_at") + completedAt DateTime? @map("completed_at") + resultUrl String? @map("result_url") + requirementCoverage Json @default("[]") @map("requirement_coverage") + logs Json @default("[]") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("version_plans") +} + +model DevTask { + id String @default(cuid()) + versionId String @map("version_id") + productId String @map("product_id") + projectId String @map("project_id") + requirementId String? @map("requirement_id") + requirementProductId String? @map("requirement_product_id") + categoryId String? @map("category_id") + code String + title String + description String @default("") + status String @default("todo") + priority Int @default(0) + assigneeId String? @map("assignee_id") + creatorId String? @map("creator_id") + isBlocked Boolean @default(false) @map("is_blocked") + blockReason String? @map("block_reason") + expectedStartAt DateTime? @map("expected_start_at") + expectedEndAt DateTime? @map("expected_end_at") + startDate DateTime? @map("start_date") + completedAt DateTime? @map("completed_at") + estimateHours Float? @map("estimate_hours") + aiEstimateHours Float? @map("ai_estimate_hours") + references Json @default("[]") + aiDraft Boolean @default(false) @map("ai_draft") + aiDraftAt DateTime? @map("ai_draft_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([id, versionId]) + @@unique([versionId, code]) + @@map("dev_tasks") +} + +model TestCase { + id String @default(cuid()) + versionId String @map("version_id") + productId String @map("product_id") + projectId String @map("project_id") + requirementId String? @map("requirement_id") + requirementProductId String? @map("requirement_product_id") + categoryId String? @map("category_id") + code String + title String + description String @default("") + status String @default("pending") + roundNo Int @default(1) @map("round_no") + priority Int @default(0) + assigneeId String? @map("assignee_id") + creatorId String? @map("creator_id") + plannedTestAt DateTime? @map("planned_test_at") + plannedEndAt DateTime? @map("planned_end_at") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + estimateHours Float? @map("estimate_hours") + aiEstimateHours Float? @map("ai_estimate_hours") + references Json @default("[]") + aiDraft Boolean @default(false) @map("ai_draft") + aiDraftAt DateTime? @map("ai_draft_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([id, versionId]) + @@unique([versionId, code]) + @@map("test_cases") +} + +model Bug { + id String @default(cuid()) + versionId String @map("version_id") + productId String @map("product_id") + projectId String @map("project_id") + testCaseId String? @map("test_case_id") + testCaseVersionId String? @map("test_case_version_id") + code String + title String + description String @default("") + status String @default("open") + severity String @default("normal") + priority Int @default(0) + assigneeId String? @map("assignee_id") + reporterId String? @map("reporter_id") + plannedFixAt DateTime? @map("planned_fix_at") + resolvedAt DateTime? @map("resolved_at") + closedAt DateTime? @map("closed_at") + resolution String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([id, versionId]) + @@unique([versionId, code]) + @@map("bugs") +} + +model WorkActivity { + id String @default(cuid()) + versionId String? @map("version_id") + productId String? @map("product_id") + projectId String? @map("project_id") + actorId String? @map("actor_id") + actorName String @default("") @map("actor_name") + sourceType String @map("source_type") + sourceId String @map("source_id") + sourceVersionId String? @map("source_version_id") + action String + title String + metadata Json @default("{}") + occurredAt DateTime @map("occurred_at") + createdAt DateTime @default(now()) @map("created_at") + + @@id([id, createdAt]) + @@map("work_activities") +} + +model TaskWorklog { + id String @default(cuid()) + versionId String? @map("version_id") + productId String? @map("product_id") + projectId String? @map("project_id") + userId String? @map("user_id") + sourceType String @map("source_type") + sourceId String @map("source_id") + sourceVersionId String? @map("source_version_id") + workDate DateTime @db.Date @map("work_date") + hours Float @default(0) + content String @default("") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([id, createdAt]) + @@map("task_worklogs") +} + +model OvertimeRecord { + id String @default(cuid()) + productId String? @map("product_id") + projectId String? @map("project_id") + versionId String? @map("version_id") + userId String? @map("user_id") + reason String @default("") + startAt DateTime @map("start_at") + endAt DateTime @map("end_at") + hours Float @default(0) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([id, createdAt]) + @@map("overtime_records") +} + +model XiaobaoRiskSnapshot { + id String @default(cuid()) + versionId String @map("version_id") + snapshotDate DateTime @db.Date @map("snapshot_date") + riskLevel String @map("risk_level") + riskScore Int @default(0) @map("risk_score") + riskSignature String @map("risk_signature") + snapshot Json + createdAt DateTime @default(now()) @map("created_at") + + @@id([id, createdAt]) + @@map("xiaobao_risk_snapshots") +} + +model XiaobaoRiskInsight { + id String @default(cuid()) + versionId String @map("version_id") + riskSignature String @map("risk_signature") + status String @default("generated") + insight Json + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([id, createdAt]) + @@map("xiaobao_risk_insights") +} + +model AiLog { + id String @default(cuid()) + provider String + model String + operation String + status String + promptTokens Int @default(0) @map("prompt_tokens") + completionTokens Int @default(0) @map("completion_tokens") + durationMs Int @default(0) @map("duration_ms") + errorMessage String? @map("error_message") + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") + + @@id([id, createdAt]) + @@map("ai_logs") +} + +model XiaobaoRiskSummary { + versionId String @id @map("version_id") + riskLevel String @map("risk_level") + riskScore Int @default(0) @map("risk_score") + confidence Int @default(0) + forecastReleaseDate DateTime? @map("forecast_release_date") + riskSignature String @map("risk_signature") + summary Json + dirty Boolean @default(false) + recomputedAt DateTime? @map("recomputed_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("xiaobao_risk_summaries") +} + model AppData { key String @id value Json diff --git a/apps/server/prisma/v22-partitioned-schema.spec.ts b/apps/server/prisma/v22-partitioned-schema.spec.ts new file mode 100644 index 0000000..d72cc23 --- /dev/null +++ b/apps/server/prisma/v22-partitioned-schema.spec.ts @@ -0,0 +1,104 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const migrationPath = join( + process.cwd(), + 'prisma', + 'migrations', + '20260703000000_v22_partitioned_domain_schema', + 'migration.sql', +); + +function readMigrationSql() { + if (!existsSync(migrationPath)) { + throw new Error(`Missing V2.2 migration: ${migrationPath}`); + } + return readFileSync(migrationPath, 'utf8'); +} + +function expectPartitionedTable( + sql: string, + table: string, + method: 'HASH' | 'RANGE', + key: string, + primaryKey: string[], +) { + const quotedPrimaryKey = primaryKey.map((column) => `"${column}"`).join(', '); + expect(sql).toMatch( + new RegExp( + `CREATE TABLE "${table}"[\\s\\S]*CONSTRAINT "${table}_pkey" PRIMARY KEY \\(${quotedPrimaryKey}\\)[\\s\\S]*PARTITION BY ${method} \\("${key}"\\);`, + ), + ); +} + +function expectHashPartitions(sql: string, table: string, count: number) { + const matches = sql.match( + new RegExp(`PARTITION OF "${table}" FOR VALUES WITH \\(MODULUS ${count}, REMAINDER`, 'g'), + ); + expect(matches).toHaveLength(count); +} + +describe('V2.2 partitioned domain schema migration', () => { + it('keeps every hash partitioned business table primary key anchored by the partition key', () => { + const sql = readMigrationSql(); + + expectPartitionedTable(sql, 'requirements', 'HASH', 'product_id', ['id', 'product_id']); + expectPartitionedTable(sql, 'dev_tasks', 'HASH', 'version_id', ['id', 'version_id']); + expectPartitionedTable(sql, 'test_cases', 'HASH', 'version_id', ['id', 'version_id']); + expectPartitionedTable(sql, 'bugs', 'HASH', 'version_id', ['id', 'version_id']); + + expectHashPartitions(sql, 'requirements', 16); + expectHashPartitions(sql, 'dev_tasks', 16); + expectHashPartitions(sql, 'test_cases', 16); + expectHashPartitions(sql, 'bugs', 16); + }); + + it('includes partition keys in business unique constraints', () => { + const sql = readMigrationSql(); + + expect(sql).toMatch(/CONSTRAINT "requirements_product_id_code_key" UNIQUE \("product_id", "code"\)/); + expect(sql).toMatch(/CONSTRAINT "dev_tasks_version_id_code_key" UNIQUE \("version_id", "code"\)/); + expect(sql).toMatch(/CONSTRAINT "test_cases_version_id_code_key" UNIQUE \("version_id", "code"\)/); + expect(sql).toMatch(/CONSTRAINT "bugs_version_id_code_key" UNIQUE \("version_id", "code"\)/); + }); + + it('uses composite foreign keys when a referenced table is partitioned', () => { + const sql = readMigrationSql(); + + expect(sql).toMatch( + /FOREIGN KEY \("requirement_id", "requirement_product_id"\) REFERENCES "requirements"\("id", "product_id"\)/, + ); + expect(sql).toMatch( + /FOREIGN KEY \("test_case_id", "test_case_version_id"\) REFERENCES "test_cases"\("id", "version_id"\)/, + ); + }); + + it('range partitions append-only evidence and snapshot tables by created_at', () => { + const sql = readMigrationSql(); + + expectPartitionedTable(sql, 'work_activities', 'RANGE', 'created_at', ['id', 'created_at']); + expectPartitionedTable(sql, 'task_worklogs', 'RANGE', 'created_at', ['id', 'created_at']); + expectPartitionedTable(sql, 'overtime_records', 'RANGE', 'created_at', ['id', 'created_at']); + expectPartitionedTable(sql, 'xiaobao_risk_snapshots', 'RANGE', 'created_at', ['id', 'created_at']); + expectPartitionedTable(sql, 'ai_logs', 'RANGE', 'created_at', ['id', 'created_at']); + + for (const table of [ + 'work_activities', + 'task_worklogs', + 'overtime_records', + 'xiaobao_risk_snapshots', + 'ai_logs', + ]) { + expect(sql).toMatch(new RegExp(`CREATE TABLE "${table}_default" PARTITION OF "${table}" DEFAULT;`)); + } + }); + + it('keeps Xiaobao current summaries as one row per version instead of partitioned history', () => { + const sql = readMigrationSql(); + + expect(sql).toMatch( + /CREATE TABLE "xiaobao_risk_summaries"[\s\S]*CONSTRAINT "xiaobao_risk_summaries_pkey" PRIMARY KEY \("version_id"\)/, + ); + expect(sql).not.toMatch(/CREATE TABLE "xiaobao_risk_summaries"[\s\S]*PARTITION BY/); + }); +}); diff --git a/apps/server/prisma/v22-prisma-models.spec.ts b/apps/server/prisma/v22-prisma-models.spec.ts new file mode 100644 index 0000000..d0d4f7c --- /dev/null +++ b/apps/server/prisma/v22-prisma-models.spec.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const schemaPath = join(process.cwd(), 'prisma', 'schema.prisma'); + +function readSchema() { + return readFileSync(schemaPath, 'utf8'); +} + +function modelBlock(schema: string, modelName: string) { + const match = schema.match(new RegExp(`model ${modelName} \\{[\\s\\S]*?\\n\\}`)); + if (!match) throw new Error(`Missing Prisma model: ${modelName}`); + return match[0]; +} + +describe('V2.2 Prisma partitioned model shape', () => { + it('models partitioned business tables with composite ids that include the partition key', () => { + const schema = readSchema(); + + expect(modelBlock(schema, 'Requirement')).toMatch(/@@id\(\[id, productId\]\)/); + expect(modelBlock(schema, 'DevTask')).toMatch(/@@id\(\[id, versionId\]\)/); + expect(modelBlock(schema, 'TestCase')).toMatch(/@@id\(\[id, versionId\]\)/); + expect(modelBlock(schema, 'Bug')).toMatch(/@@id\(\[id, versionId\]\)/); + }); + + it('keeps partition-key-scoped unique business codes in Prisma models', () => { + const schema = readSchema(); + + expect(modelBlock(schema, 'Requirement')).toMatch(/@@unique\(\[productId, code\]\)/); + expect(modelBlock(schema, 'DevTask')).toMatch(/@@unique\(\[versionId, code\]\)/); + expect(modelBlock(schema, 'TestCase')).toMatch(/@@unique\(\[versionId, code\]\)/); + expect(modelBlock(schema, 'Bug')).toMatch(/@@unique\(\[versionId, code\]\)/); + }); + + it('models append-only partitioned tables with createdAt in the composite id', () => { + const schema = readSchema(); + + expect(modelBlock(schema, 'WorkActivity')).toMatch(/@@id\(\[id, createdAt\]\)/); + expect(modelBlock(schema, 'TaskWorklog')).toMatch(/@@id\(\[id, createdAt\]\)/); + expect(modelBlock(schema, 'OvertimeRecord')).toMatch(/@@id\(\[id, createdAt\]\)/); + expect(modelBlock(schema, 'XiaobaoRiskSnapshot')).toMatch(/@@id\(\[id, createdAt\]\)/); + expect(modelBlock(schema, 'AiLog')).toMatch(/@@id\(\[id, createdAt\]\)/); + }); + + it('keeps Xiaobao current summaries keyed by version id only', () => { + const schema = readSchema(); + + expect(modelBlock(schema, 'XiaobaoRiskSummary')).toMatch(/versionId\s+String\s+@id/); + }); +}); diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 78cf077..2c0023a 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -5,9 +5,10 @@ import { RequirementModule } from './modules/requirement/requirement.module'; import { AiModule } from './modules/ai/ai.module'; import { ConfigModule } from './modules/config/config.module'; import { DataModule } from './modules/data/data.module'; +import { MigrationModule } from './modules/migration/migration.module'; @Module({ - imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule], + imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule], controllers: [], providers: [], }) diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts b/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts new file mode 100644 index 0000000..7ed1d90 --- /dev/null +++ b/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts @@ -0,0 +1,415 @@ +import { mapAppDataToV22Rows } from './app-data-v22.mapper'; + +const now = '2026-01-04T10:00:00.000Z'; + +function buildAppData(): Record { + return { + 'products-overview': [ + { + id: 'product-1', + name: 'FTB', + description: 'Product suite', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + projects: [ + { + id: 'project-1', + name: 'CRM', + description: 'Customer project', + createdAt: '2026-01-01T01:00:00.000Z', + }, + ], + versions: [ + { + id: 'version-1', + name: 'CRM V1.0', + description: 'First release', + status: 'in_progress', + releaseDate: '2026-02-01', + expectedReleaseDate: '2026-02-03', + createdAt: '2026-01-01T02:00:00.000Z', + updatedAt: '2026-01-02T02:00:00.000Z', + }, + ], + }, + ], + requirements: { + requirements: [ + { + id: 'req-1', + code: 'REQ-001', + title: 'Customer import', + description: 'Import customers in bulk', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + sourceType: 'customer', + sourceTarget: 'ACME', + platforms: ['Web', 'App'], + typeId: 'type-feature', + status: 'adopted', + priority: 'P1', + creator: 'member-pm', + createdAt: '2026-01-02T08:00:00.000Z', + updatedAt: '2026-01-02T09:00:00.000Z', + }, + ], + }, + 'task-categories': [ + { + id: 'cat-fe', + code: 'frontend_development', + name: 'Frontend development', + group: 'development', + isSystem: true, + }, + { + id: 'cat-test', + code: 'test_functional', + name: 'Functional test', + group: 'testing', + isSystem: true, + }, + ], + 'version-plans': [ + { + id: 'plan-1', + versionId: 'version-1', + type: 'product', + title: 'Product plan', + owner: 'member-pm', + startTime: '2026-01-02T09:00:00.000Z', + endTime: '2026-01-02T18:00:00.000Z', + status: 'completed', + requirementCoverage: [ + { + requirementId: 'req-1', + status: 'completed', + updatedAt: now, + updatedBy: 'member-pm', + }, + ], + logs: [ + { + id: 'plan-log-1', + type: 'requirement_progress', + createdAt: now, + actor: 'member-pm', + title: 'Requirement completed', + }, + ], + resultUrl: 'https://example.com/prototype', + createdAt: '2026-01-02T08:30:00.000Z', + completedAt: '2026-01-02T17:00:00.000Z', + addedBy: 'member-pm', + }, + ], + 'dev-tasks': [ + { + id: 'dev-1', + taskNo: 'DEV-001', + requirementId: 'req-1', + title: 'Build import UI', + description: 'Upload and preview CSV', + categoryId: 'cat-fe', + assigneeId: 'member-dev', + priority: 'P0', + expectedStartAt: '2026-01-03T09:00:00.000Z', + expectedEndAt: '2026-01-03T18:00:00.000Z', + actualStartAt: '2026-01-03T10:00:00.000Z', + actualEndAt: '2026-01-03T17:30:00.000Z', + estimateHours: 6, + aiEstimateHours: 7, + status: 'submitted', + isBlocked: false, + references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }], + aiDraft: false, + createdBy: 'member-pm', + createdAt: '2026-01-03T08:30:00.000Z', + updatedAt: '2026-01-03T17:30:00.000Z', + }, + ], + 'test-cases': [ + { + id: 'tc-1', + caseNo: 'TC-001', + versionId: 'version-1', + requirementId: 'req-1', + title: 'Import success path', + description: 'Upload a valid CSV', + categoryId: 'cat-test', + priority: 'P2', + assigneeId: 'member-test', + status: 'failed', + roundNo: 1, + plannedTestAt: '2026-01-04T09:00:00.000Z', + plannedEndAt: '2026-01-04T12:00:00.000Z', + startedAt: '2026-01-04T09:30:00.000Z', + completedAt: '2026-01-04T10:30:00.000Z', + estimateHours: 2, + aiEstimateHours: 2.5, + references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }], + createdBy: 'member-test', + createdAt: '2026-01-04T08:00:00.000Z', + updatedAt: '2026-01-04T10:30:00.000Z', + }, + ], + bugs: [ + { + id: 'bug-1', + bugNo: 'BUG-001', + versionId: 'version-1', + testCaseId: 'tc-1', + requirementId: 'req-1', + title: 'Preview count is wrong', + description: 'Total rows shows 0', + severity: 'major', + priority: 'P1', + reportedBy: 'member-test', + assigneeId: 'member-dev', + status: 'fixing', + plannedFixAt: '2026-01-04T18:00:00.000Z', + createdAt: '2026-01-04T10:40:00.000Z', + updatedAt: '2026-01-04T11:00:00.000Z', + }, + ], + 'work-activities': [ + { + id: 'activity-1', + actorId: 'member-dev', + date: '2026-01-03', + occurredAt: '2026-01-03T10:00:00.000Z', + sourceType: 'dev_task', + sourceId: 'dev-1', + action: 'dev_task_started', + category: 'progress', + title: 'Started import UI', + summary: 'Started development', + metadata: { toStatus: 'in_progress' }, + }, + ], + 'task-worklogs': [ + { + id: 'worklog-1', + taskId: 'dev-1', + userId: 'member-dev', + date: '2026-01-03', + hours: 2.5, + workContent: 'Built CSV preview', + createdAt: '2026-01-03T19:00:00.000Z', + }, + ], + overtime: { + records: [ + { + id: 'overtime-1', + projectId: 'project-1', + versionId: 'version-1', + person: 'member-dev', + startTime: '2026-01-03T18:00:00.000Z', + endTime: '2026-01-03T20:00:00.000Z', + duration: 2, + reasonId: 'reason-3', + remark: 'Release sprint', + createdAt: '2026-01-03T20:10:00.000Z', + }, + ], + }, + 'xiaobao-risk-snapshots': [ + { + versionId: 'version-1', + date: '2026-01-04', + riskScore: 72, + riskLevel: 'at_risk', + forecastReleaseDate: '2026-02-05', + openBugCount: 3, + criticalBugCount: 1, + failedTestCount: 2, + blockedCount: 1, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-01-04T12:00:00.000Z', + }, + ], + 'xiaobao-risk-insights': [ + { + versionId: 'version-1', + riskSignature: 'risk-signature-1', + insight: { summary: 'Risk is rising', why: ['Bug count rose'] }, + generatedAt: '2026-01-04T12:05:00.000Z', + providerInfo: { providerId: 'anthropic', model: 'claude-test' }, + }, + ], + }; +} + +describe('mapAppDataToV22Rows', () => { + it('maps AppData entities into partition-key-ready relational rows', () => { + const result = mapAppDataToV22Rows(buildAppData()); + + expect(result.products).toEqual([ + expect.objectContaining({ id: 'product-1', name: 'FTB' }), + ]); + expect(result.projects).toEqual([ + expect.objectContaining({ id: 'project-1', productId: 'product-1', name: 'CRM' }), + ]); + expect(result.versions).toEqual([ + expect.objectContaining({ + id: 'version-1', + productId: 'product-1', + projectId: 'project-1', + releaseDate: '2026-02-01', + }), + ]); + + expect(result.requirements).toEqual([ + expect.objectContaining({ + id: 'req-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + code: 'REQ-001', + priority: 1, + platform: 'Web,App', + creatorId: 'member-pm', + }), + ]); + expect(result.devTasks).toEqual([ + expect.objectContaining({ + id: 'dev-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + requirementId: 'req-1', + requirementProductId: 'product-1', + code: 'DEV-001', + priority: 0, + startDate: '2026-01-03T10:00:00.000Z', + completedAt: '2026-01-03T17:30:00.000Z', + }), + ]); + expect(result.testCases).toEqual([ + expect.objectContaining({ + id: 'tc-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + requirementProductId: 'product-1', + code: 'TC-001', + priority: 2, + }), + ]); + expect(result.bugs).toEqual([ + expect.objectContaining({ + id: 'bug-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + testCaseId: 'tc-1', + testCaseVersionId: 'version-1', + code: 'BUG-001', + priority: 1, + }), + ]); + expect(result.skipped).toEqual([]); + }); + + it('maps append-only activity, worklog, overtime, and Xiaobao rows with createdAt partition keys', () => { + const result = mapAppDataToV22Rows(buildAppData()); + + expect(result.workActivities).toEqual([ + expect.objectContaining({ + id: 'activity-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + sourceType: 'dev_task', + sourceId: 'dev-1', + sourceVersionId: 'version-1', + occurredAt: '2026-01-03T10:00:00.000Z', + createdAt: '2026-01-03T10:00:00.000Z', + }), + ]); + expect(result.taskWorklogs).toEqual([ + expect.objectContaining({ + id: 'worklog-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + sourceType: 'dev_task', + sourceId: 'dev-1', + sourceVersionId: 'version-1', + workDate: '2026-01-03', + createdAt: '2026-01-03T19:00:00.000Z', + }), + ]); + expect(result.overtimeRecords).toEqual([ + expect.objectContaining({ + id: 'overtime-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + userId: 'member-dev', + reason: 'reason-3', + hours: 2, + createdAt: '2026-01-03T20:10:00.000Z', + }), + ]); + expect(result.xiaobaoRiskSnapshots).toEqual([ + expect.objectContaining({ + versionId: 'version-1', + snapshotDate: '2026-01-04', + riskLevel: 'at_risk', + riskScore: 72, + riskSignature: 'version-1|2026-01-04|72|at_risk|2026-02-05|3|1|2|1|0|80', + createdAt: '2026-01-04T12:00:00.000Z', + }), + ]); + expect(result.xiaobaoRiskInsights).toEqual([ + expect.objectContaining({ + versionId: 'version-1', + riskSignature: 'risk-signature-1', + status: 'generated', + createdAt: '2026-01-04T12:05:00.000Z', + }), + ]); + expect(result.xiaobaoRiskSummaries).toEqual([ + expect.objectContaining({ + versionId: 'version-1', + riskLevel: 'at_risk', + riskScore: 72, + confidence: 80, + riskSignature: 'version-1|2026-01-04|72|at_risk|2026-02-05|3|1|2|1|0|80', + }), + ]); + }); + + it('skips rows that cannot resolve the partition key instead of inventing unsafe ownership', () => { + const appData = buildAppData(); + appData['dev-tasks'].push({ + id: 'dev-orphan', + taskNo: 'DEV-999', + requirementId: 'missing-req', + title: 'Cannot resolve version', + categoryId: 'cat-fe', + assigneeId: 'member-dev', + priority: 'P2', + expectedStartAt: '', + expectedEndAt: '', + status: 'todo', + isBlocked: false, + createdBy: 'member-pm', + createdAt: now, + updatedAt: now, + }); + + const result = mapAppDataToV22Rows(appData); + + expect(result.devTasks.map((task: { id: string }) => task.id)).toEqual(['dev-1']); + expect(result.skipped).toContainEqual({ + key: 'dev-tasks', + id: 'dev-orphan', + reason: 'missing version partition context', + }); + }); +}); diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.ts b/apps/server/src/modules/migration/app-data-v22.mapper.ts new file mode 100644 index 0000000..950bac6 --- /dev/null +++ b/apps/server/src/modules/migration/app-data-v22.mapper.ts @@ -0,0 +1,922 @@ +type AppDataRecord = Record; + +interface SkipReason { + key: string; + id?: string; + reason: string; +} + +interface ProductRow { + id: string; + name: string; + description: string; + createdAt?: string; + updatedAt?: string; +} + +interface ProjectRow { + id: string; + productId: string; + name: string; + description: string; + createdAt?: string; + updatedAt?: string; +} + +interface VersionRow { + id: string; + productId: string; + projectId?: string; + name: string; + description: string; + releaseDate?: string; + createdAt?: string; + updatedAt?: string; +} + +interface RequirementRow { + id: string; + productId: string; + projectId?: string; + versionId?: string; + code: string; + title: string; + description: string; + status: string; + priority: number; + type?: string; + sourceType?: string; + sourceTarget?: string; + platform?: string; + creatorId?: string; + createdAt?: string; + updatedAt?: string; +} + +interface TaskCategoryRow { + id: string; + name: string; + code?: string; + group: string; + isSystem: boolean; + createdAt?: string; + updatedAt?: string; +} + +interface VersionPlanRow { + id: string; + versionId: string; + productId: string; + projectId?: string; + type: string; + title: string; + status: string; + ownerId?: string; + expectedStartAt?: string; + expectedEndAt?: string; + actualStartAt?: string; + completedAt?: string; + resultUrl?: string; + requirementCoverage: unknown[]; + logs: unknown[]; + createdAt?: string; + updatedAt?: string; +} + +interface DevTaskRow { + id: string; + versionId: string; + productId: string; + projectId: string; + requirementId?: string; + requirementProductId?: string; + categoryId?: string; + code: string; + title: string; + description: string; + status: string; + priority: number; + assigneeId?: string; + creatorId?: string; + isBlocked: boolean; + blockReason?: string; + expectedStartAt?: string; + expectedEndAt?: string; + startDate?: string; + completedAt?: string; + estimateHours?: number; + aiEstimateHours?: number; + references: unknown[]; + aiDraft: boolean; + aiDraftAt?: string; + createdAt?: string; + updatedAt?: string; +} + +interface TestCaseRow { + id: string; + versionId: string; + productId: string; + projectId: string; + requirementId?: string; + requirementProductId?: string; + categoryId?: string; + code: string; + title: string; + description: string; + status: string; + roundNo: number; + priority: number; + assigneeId?: string; + creatorId?: string; + plannedTestAt?: string; + plannedEndAt?: string; + startedAt?: string; + completedAt?: string; + estimateHours?: number; + aiEstimateHours?: number; + references: unknown[]; + aiDraft: boolean; + aiDraftAt?: string; + createdAt?: string; + updatedAt?: string; +} + +interface BugRow { + id: string; + versionId: string; + productId: string; + projectId: string; + testCaseId?: string; + testCaseVersionId?: string; + code: string; + title: string; + description: string; + status: string; + severity: string; + priority: number; + assigneeId?: string; + reporterId?: string; + plannedFixAt?: string; + resolvedAt?: string; + closedAt?: string; + resolution?: string; + createdAt?: string; + updatedAt?: string; +} + +interface WorkActivityRow { + id: string; + versionId?: string; + productId?: string; + projectId?: string; + actorId?: string; + actorName: string; + sourceType: string; + sourceId: string; + sourceVersionId?: string; + action: string; + title: string; + metadata: unknown; + occurredAt: string; + createdAt: string; +} + +interface TaskWorklogRow { + id: string; + versionId?: string; + productId?: string; + projectId?: string; + userId?: string; + sourceType: string; + sourceId: string; + sourceVersionId?: string; + workDate: string; + hours: number; + content: string; + createdAt: string; + updatedAt?: string; +} + +interface OvertimeRecordRow { + id: string; + productId?: string; + projectId?: string; + versionId?: string; + userId?: string; + reason: string; + startAt: string; + endAt: string; + hours: number; + createdAt: string; + updatedAt?: string; +} + +interface XiaobaoRiskSnapshotRow { + id: string; + versionId: string; + snapshotDate: string; + riskLevel: string; + riskScore: number; + riskSignature: string; + snapshot: unknown; + createdAt: string; +} + +interface XiaobaoRiskInsightRow { + id: string; + versionId: string; + riskSignature: string; + status: string; + insight: unknown; + createdAt: string; + updatedAt?: string; +} + +interface XiaobaoRiskSummaryRow { + versionId: string; + riskLevel: string; + riskScore: number; + confidence: number; + forecastReleaseDate?: string; + riskSignature: string; + summary: unknown; + dirty: boolean; + recomputedAt?: string; + updatedAt?: string; +} + +interface SourceContext { + versionId?: string; + productId?: string; + projectId?: string; +} + +export interface V22MappedRows { + products: ProductRow[]; + projects: ProjectRow[]; + versions: VersionRow[]; + requirements: RequirementRow[]; + taskCategories: TaskCategoryRow[]; + versionPlans: VersionPlanRow[]; + devTasks: DevTaskRow[]; + testCases: TestCaseRow[]; + bugs: BugRow[]; + workActivities: WorkActivityRow[]; + taskWorklogs: TaskWorklogRow[]; + overtimeRecords: OvertimeRecordRow[]; + xiaobaoRiskSnapshots: XiaobaoRiskSnapshotRow[]; + xiaobaoRiskInsights: XiaobaoRiskInsightRow[]; + xiaobaoRiskSummaries: XiaobaoRiskSummaryRow[]; + aiLogs: unknown[]; + skipped: SkipReason[]; +} + +export function mapAppDataToV22Rows(appData: Record): V22MappedRows { + const skipped: SkipReason[] = []; + const products: ProductRow[] = []; + const projects: ProjectRow[] = []; + const versions: VersionRow[] = []; + const requirements: RequirementRow[] = []; + const taskCategories: TaskCategoryRow[] = []; + const versionPlans: VersionPlanRow[] = []; + const devTasks: DevTaskRow[] = []; + const testCases: TestCaseRow[] = []; + const bugs: BugRow[] = []; + const workActivities: WorkActivityRow[] = []; + const taskWorklogs: TaskWorklogRow[] = []; + const overtimeRecords: OvertimeRecordRow[] = []; + const xiaobaoRiskSnapshots: XiaobaoRiskSnapshotRow[] = []; + const xiaobaoRiskInsights: XiaobaoRiskInsightRow[] = []; + const xiaobaoRiskSummaries: XiaobaoRiskSummaryRow[] = []; + + const productById = new Map(); + const projectById = new Map(); + const versionById = new Map(); + const requirementById = new Map(); + const devTaskById = new Map(); + const testCaseById = new Map(); + const bugById = new Map(); + const versionPlanById = new Map(); + + const productRecords = asArray(appData['products-overview']); + for (const [productIndex, product] of productRecords.entries()) { + const productId = stringField(product, 'id') ?? `product-${productIndex + 1}`; + const productRow: ProductRow = { + id: productId, + name: stringField(product, 'name') ?? productId, + description: stringField(product, 'description') ?? '', + createdAt: stringField(product, 'createdAt'), + updatedAt: stringField(product, 'updatedAt') ?? stringField(product, 'createdAt'), + }; + products.push(productRow); + productById.set(productId, productRow); + + const productProjects = asArray(product.projects); + for (const [projectIndex, project] of productProjects.entries()) { + const projectId = stringField(project, 'id') ?? `${productId}-project-${projectIndex + 1}`; + const projectRow: ProjectRow = { + id: projectId, + productId, + name: stringField(project, 'name') ?? projectId, + description: stringField(project, 'description') ?? '', + createdAt: stringField(project, 'createdAt') ?? productRow.createdAt, + updatedAt: stringField(project, 'updatedAt') ?? stringField(project, 'createdAt') ?? productRow.updatedAt, + }; + projects.push(projectRow); + projectById.set(projectId, projectRow); + } + + const productVersions = asArray(product.versions); + for (const [versionIndex, version] of productVersions.entries()) { + const versionId = stringField(version, 'id') ?? `${productId}-version-${versionIndex + 1}`; + const projectId = stringField(version, 'projectId') ?? inferProjectIdForVersion(version, productProjects); + const versionRow: VersionRow = { + id: versionId, + productId, + projectId, + name: stringField(version, 'name') ?? versionId, + description: stringField(version, 'description') ?? '', + releaseDate: stringField(version, 'releaseDate') ?? stringField(version, 'expectedReleaseDate'), + createdAt: stringField(version, 'createdAt') ?? productRow.createdAt, + updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt, + }; + versions.push(versionRow); + versionById.set(versionId, versionRow); + } + } + + const defaultProductId = products[0]?.id; + const requirementCode = scopedCodeFactory('REQ'); + for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) { + const id = stringField(requirement, 'id') ?? `requirement-${index + 1}`; + const productId = stringField(requirement, 'productId') ?? defaultProductId; + if (!productId) { + skipped.push({ key: 'requirements', id, reason: 'missing product partition context' }); + continue; + } + const versionId = stringField(requirement, 'versionId'); + const version = versionId ? versionById.get(versionId) : undefined; + const platforms = unknownArray(requirement.platforms) + .map((platform) => stringFromUnknown(platform)) + .filter((platform): platform is string => Boolean(platform)); + const row: RequirementRow = { + id, + productId, + projectId: stringField(requirement, 'projectId') ?? version?.projectId, + versionId, + code: requirementCode(productId, stringField(requirement, 'code'), index), + title: stringField(requirement, 'title') ?? id, + description: stringField(requirement, 'description') ?? '', + status: stringField(requirement, 'status') ?? 'pending_review', + priority: priorityRank(requirement.priority), + type: stringField(requirement, 'type') ?? stringField(requirement, 'typeId'), + sourceType: stringField(requirement, 'sourceType'), + sourceTarget: stringField(requirement, 'sourceTarget'), + platform: platforms.join(',') || stringField(requirement, 'platform'), + creatorId: stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'), + createdAt: stringField(requirement, 'createdAt'), + updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'), + }; + requirements.push(row); + requirementById.set(row.id, row); + } + + for (const [index, category] of asArray(appData['task-categories']).entries()) { + const id = stringField(category, 'id') ?? `category-${index + 1}`; + taskCategories.push({ + id, + name: stringField(category, 'name') ?? id, + code: stringField(category, 'code'), + group: stringField(category, 'group') ?? 'other', + isSystem: booleanField(category, 'isSystem') ?? false, + createdAt: stringField(category, 'createdAt'), + updatedAt: stringField(category, 'updatedAt') ?? stringField(category, 'createdAt'), + }); + } + + for (const [index, plan] of asArray(appData['version-plans']).entries()) { + const id = stringField(plan, 'id') ?? `version-plan-${index + 1}`; + const versionId = stringField(plan, 'versionId'); + const version = versionId ? versionById.get(versionId) : undefined; + if (!versionId || !version?.productId) { + skipped.push({ key: 'version-plans', id, reason: 'missing version partition context' }); + continue; + } + const row: VersionPlanRow = { + id, + versionId, + productId: version.productId, + projectId: version.projectId, + type: stringField(plan, 'type') ?? 'product', + title: stringField(plan, 'title') ?? id, + status: stringField(plan, 'status') ?? 'pending', + ownerId: stringField(plan, 'ownerId') ?? stringField(plan, 'owner'), + expectedStartAt: stringField(plan, 'expectedStartAt') ?? stringField(plan, 'startTime'), + expectedEndAt: stringField(plan, 'expectedEndAt') ?? stringField(plan, 'endTime'), + actualStartAt: stringField(plan, 'actualStartAt'), + completedAt: stringField(plan, 'completedAt'), + resultUrl: stringField(plan, 'resultUrl'), + requirementCoverage: unknownArray(plan.requirementCoverage), + logs: unknownArray(plan.logs), + createdAt: stringField(plan, 'createdAt'), + updatedAt: stringField(plan, 'updatedAt') ?? stringField(plan, 'completedAt') ?? stringField(plan, 'createdAt'), + }; + versionPlans.push(row); + versionPlanById.set(row.id, row); + } + + const devCode = scopedCodeFactory('DEV'); + for (const [index, task] of asArray(appData['dev-tasks']).entries()) { + const id = stringField(task, 'id') ?? `dev-task-${index + 1}`; + const context = resolveVersionContext(task, requirementById, versionById); + if (!context.versionId || !context.productId || !context.projectId) { + skipped.push({ key: 'dev-tasks', id, reason: 'missing version partition context' }); + continue; + } + const requirementId = stringField(task, 'requirementId'); + const requirement = requirementId ? requirementById.get(requirementId) : undefined; + const row: DevTaskRow = { + id, + versionId: context.versionId, + productId: context.productId, + projectId: context.projectId, + requirementId, + requirementProductId: requirement?.productId, + categoryId: stringField(task, 'categoryId'), + code: devCode(context.versionId, stringField(task, 'code') ?? stringField(task, 'taskNo'), index), + title: stringField(task, 'title') ?? id, + description: stringField(task, 'description') ?? '', + status: stringField(task, 'status') ?? 'todo', + priority: priorityRank(task.priority), + assigneeId: stringField(task, 'assigneeId'), + creatorId: stringField(task, 'creatorId') ?? stringField(task, 'createdBy'), + isBlocked: booleanField(task, 'isBlocked') ?? false, + blockReason: stringField(task, 'blockReason'), + expectedStartAt: stringField(task, 'expectedStartAt'), + expectedEndAt: stringField(task, 'expectedEndAt'), + startDate: stringField(task, 'startDate') ?? stringField(task, 'actualStartAt'), + completedAt: stringField(task, 'completedAt') ?? stringField(task, 'actualEndAt'), + estimateHours: numberField(task, 'estimateHours'), + aiEstimateHours: numberField(task, 'aiEstimateHours'), + references: unknownArray(task.references), + aiDraft: booleanField(task, 'aiDraft') ?? false, + aiDraftAt: stringField(task, 'aiDraftAt'), + createdAt: stringField(task, 'createdAt'), + updatedAt: stringField(task, 'updatedAt') ?? stringField(task, 'createdAt'), + }; + devTasks.push(row); + devTaskById.set(row.id, row); + } + + const testCode = scopedCodeFactory('TC'); + for (const [index, testCase] of asArray(appData['test-cases']).entries()) { + const id = stringField(testCase, 'id') ?? `test-case-${index + 1}`; + const context = resolveVersionContext(testCase, requirementById, versionById); + if (!context.versionId || !context.productId || !context.projectId) { + skipped.push({ key: 'test-cases', id, reason: 'missing version partition context' }); + continue; + } + const requirementId = stringField(testCase, 'requirementId'); + const requirement = requirementId ? requirementById.get(requirementId) : undefined; + const row: TestCaseRow = { + id, + versionId: context.versionId, + productId: context.productId, + projectId: context.projectId, + requirementId, + requirementProductId: requirement?.productId, + categoryId: stringField(testCase, 'categoryId'), + code: testCode(context.versionId, stringField(testCase, 'code') ?? stringField(testCase, 'caseNo'), index), + title: stringField(testCase, 'title') ?? id, + description: stringField(testCase, 'description') ?? '', + status: stringField(testCase, 'status') ?? 'pending', + roundNo: integerField(testCase, 'roundNo') ?? 1, + priority: priorityRank(testCase.priority), + assigneeId: stringField(testCase, 'assigneeId'), + creatorId: stringField(testCase, 'creatorId') ?? stringField(testCase, 'createdBy'), + plannedTestAt: stringField(testCase, 'plannedTestAt'), + plannedEndAt: stringField(testCase, 'plannedEndAt'), + startedAt: stringField(testCase, 'startedAt'), + completedAt: stringField(testCase, 'completedAt'), + estimateHours: numberField(testCase, 'estimateHours'), + aiEstimateHours: numberField(testCase, 'aiEstimateHours'), + references: unknownArray(testCase.references), + aiDraft: booleanField(testCase, 'aiDraft') ?? false, + aiDraftAt: stringField(testCase, 'aiDraftAt'), + createdAt: stringField(testCase, 'createdAt'), + updatedAt: stringField(testCase, 'updatedAt') ?? stringField(testCase, 'createdAt'), + }; + testCases.push(row); + testCaseById.set(row.id, row); + } + + const bugCode = scopedCodeFactory('BUG'); + for (const [index, bug] of asArray(appData.bugs).entries()) { + const id = stringField(bug, 'id') ?? `bug-${index + 1}`; + const testCaseId = stringField(bug, 'testCaseId'); + const linkedTestCase = testCaseId ? testCaseById.get(testCaseId) : undefined; + const context = resolveVersionContext( + { ...bug, versionId: stringField(bug, 'versionId') ?? linkedTestCase?.versionId }, + requirementById, + versionById, + ); + if (!context.versionId || !context.productId || !context.projectId) { + skipped.push({ key: 'bugs', id, reason: 'missing version partition context' }); + continue; + } + const row: BugRow = { + id, + versionId: context.versionId, + productId: context.productId, + projectId: context.projectId, + testCaseId, + testCaseVersionId: linkedTestCase?.versionId, + code: bugCode(context.versionId, stringField(bug, 'code') ?? stringField(bug, 'bugNo'), index), + title: stringField(bug, 'title') ?? id, + description: stringField(bug, 'description') ?? '', + status: stringField(bug, 'status') ?? 'open', + severity: stringField(bug, 'severity') ?? 'minor', + priority: priorityRank(bug.priority), + assigneeId: stringField(bug, 'assigneeId'), + reporterId: stringField(bug, 'reporterId') ?? stringField(bug, 'reportedBy'), + plannedFixAt: stringField(bug, 'plannedFixAt'), + resolvedAt: stringField(bug, 'resolvedAt'), + closedAt: stringField(bug, 'closedAt'), + resolution: stringField(bug, 'resolution'), + createdAt: stringField(bug, 'createdAt'), + updatedAt: stringField(bug, 'updatedAt') ?? stringField(bug, 'createdAt'), + }; + bugs.push(row); + bugById.set(row.id, row); + } + + for (const [index, activity] of asArray(appData['work-activities']).entries()) { + const id = stringField(activity, 'id') ?? `work-activity-${index + 1}`; + const occurredAt = stringField(activity, 'occurredAt') ?? stringField(activity, 'createdAt'); + if (!occurredAt) { + skipped.push({ key: 'work-activities', id, reason: 'missing createdAt partition context' }); + continue; + } + const sourceType = stringField(activity, 'sourceType') ?? 'manual'; + const sourceId = stringField(activity, 'sourceId') ?? id; + const sourceContext = resolveSourceContext(sourceType, sourceId, { + devTaskById, + testCaseById, + bugById, + versionPlanById, + }); + workActivities.push({ + id, + versionId: sourceContext.versionId, + productId: sourceContext.productId, + projectId: sourceContext.projectId, + actorId: stringField(activity, 'actorId'), + actorName: stringField(activity, 'actorName') ?? '', + sourceType, + sourceId, + sourceVersionId: sourceContext.versionId, + action: stringField(activity, 'action') ?? 'progress_note_added', + title: stringField(activity, 'title') ?? stringField(activity, 'summary') ?? id, + metadata: recordField(activity, 'metadata') ?? {}, + occurredAt, + createdAt: stringField(activity, 'createdAt') ?? occurredAt, + }); + } + + for (const [index, worklog] of asArray(appData['task-worklogs']).entries()) { + const id = stringField(worklog, 'id') ?? `task-worklog-${index + 1}`; + const createdAt = stringField(worklog, 'createdAt'); + const sourceId = stringField(worklog, 'sourceId') ?? stringField(worklog, 'taskId') ?? id; + const sourceContext = resolveWorklogSource(sourceId, { devTaskById, testCaseById, bugById }); + taskWorklogs.push({ + id, + versionId: sourceContext.context.versionId, + productId: sourceContext.context.productId, + projectId: sourceContext.context.projectId, + userId: stringField(worklog, 'userId'), + sourceType: stringField(worklog, 'sourceType') ?? sourceContext.sourceType, + sourceId, + sourceVersionId: sourceContext.context.versionId, + workDate: stringField(worklog, 'workDate') ?? stringField(worklog, 'date') ?? sliceDate(createdAt) ?? '', + hours: numberField(worklog, 'hours') ?? 0, + content: stringField(worklog, 'content') ?? stringField(worklog, 'workContent') ?? '', + createdAt: createdAt ?? stringField(worklog, 'date') ?? '', + updatedAt: stringField(worklog, 'updatedAt') ?? createdAt, + }); + } + + for (const [index, overtime] of readNestedArray(appData.overtime, 'records').entries()) { + const id = stringField(overtime, 'id') ?? `overtime-${index + 1}`; + const versionId = stringField(overtime, 'versionId'); + const projectId = stringField(overtime, 'projectId'); + const version = versionId ? versionById.get(versionId) : undefined; + const project = projectId ? projectById.get(projectId) : undefined; + const startAt = stringField(overtime, 'startAt') ?? stringField(overtime, 'startTime'); + const endAt = stringField(overtime, 'endAt') ?? stringField(overtime, 'endTime'); + const createdAt = stringField(overtime, 'createdAt') ?? startAt; + if (!startAt || !endAt || !createdAt) { + skipped.push({ key: 'overtime', id, reason: 'missing createdAt partition context' }); + continue; + } + overtimeRecords.push({ + id, + productId: stringField(overtime, 'productId') ?? version?.productId ?? project?.productId, + projectId: projectId ?? version?.projectId, + versionId, + userId: stringField(overtime, 'userId') ?? stringField(overtime, 'person'), + reason: stringField(overtime, 'reason') ?? stringField(overtime, 'reasonId') ?? '', + startAt, + endAt, + hours: numberField(overtime, 'hours') ?? numberField(overtime, 'duration') ?? 0, + createdAt, + updatedAt: stringField(overtime, 'updatedAt') ?? createdAt, + }); + } + + const summaryByVersion = new Map(); + for (const [index, snapshot] of asArray(appData['xiaobao-risk-snapshots']).entries()) { + const versionId = stringField(snapshot, 'versionId'); + const snapshotDate = stringField(snapshot, 'snapshotDate') ?? stringField(snapshot, 'date'); + const createdAt = stringField(snapshot, 'createdAt') ?? snapshotDate; + if (!versionId || !snapshotDate || !createdAt) { + skipped.push({ + key: 'xiaobao-risk-snapshots', + id: stringField(snapshot, 'id') ?? `xiaobao-risk-snapshot-${index + 1}`, + reason: 'missing createdAt partition context', + }); + continue; + } + const riskSignature = stringField(snapshot, 'riskSignature') ?? buildRiskSignature(snapshot, versionId, snapshotDate); + const row: XiaobaoRiskSnapshotRow = { + id: stringField(snapshot, 'id') ?? `risk-snapshot-${versionId}-${snapshotDate}-${index + 1}`, + versionId, + snapshotDate, + riskLevel: stringField(snapshot, 'riskLevel') ?? 'on_track', + riskScore: integerField(snapshot, 'riskScore') ?? 0, + riskSignature, + snapshot, + createdAt, + }; + xiaobaoRiskSnapshots.push(row); + const summary: XiaobaoRiskSummaryRow = { + versionId, + riskLevel: row.riskLevel, + riskScore: row.riskScore, + confidence: integerField(snapshot, 'confidence') ?? 0, + forecastReleaseDate: stringField(snapshot, 'forecastReleaseDate'), + riskSignature, + summary: snapshot, + dirty: false, + recomputedAt: createdAt, + updatedAt: createdAt, + }; + const previous = summaryByVersion.get(versionId); + if (!previous || compareIso(summary.updatedAt, previous.updatedAt) >= 0) { + summaryByVersion.set(versionId, summary); + } + } + xiaobaoRiskSummaries.push(...summaryByVersion.values()); + + for (const [index, insight] of asArray(appData['xiaobao-risk-insights']).entries()) { + const versionId = stringField(insight, 'versionId'); + const riskSignature = stringField(insight, 'riskSignature'); + const createdAt = stringField(insight, 'createdAt') ?? stringField(insight, 'generatedAt'); + if (!versionId || !riskSignature || !createdAt) { + skipped.push({ + key: 'xiaobao-risk-insights', + id: stringField(insight, 'id') ?? `xiaobao-risk-insight-${index + 1}`, + reason: 'missing createdAt partition context', + }); + continue; + } + xiaobaoRiskInsights.push({ + id: stringField(insight, 'id') ?? `risk-insight-${versionId}-${index + 1}`, + versionId, + riskSignature, + status: stringField(insight, 'status') ?? 'generated', + insight: recordField(insight, 'insight') ?? {}, + createdAt, + updatedAt: stringField(insight, 'updatedAt') ?? createdAt, + }); + } + + return { + products, + projects, + versions, + requirements, + taskCategories, + versionPlans, + devTasks, + testCases, + bugs, + workActivities, + taskWorklogs, + overtimeRecords, + xiaobaoRiskSnapshots, + xiaobaoRiskInsights, + xiaobaoRiskSummaries, + aiLogs: [], + skipped, + }; +} + +function asRecord(value: unknown): AppDataRecord | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + return value as AppDataRecord; +} + +function asArray(value: unknown): AppDataRecord[] { + if (!Array.isArray(value)) return []; + return value.map(asRecord).filter((item): item is AppDataRecord => Boolean(item)); +} + +function unknownArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readNestedArray(value: unknown, key: string): AppDataRecord[] { + if (Array.isArray(value)) return asArray(value); + return asArray(asRecord(value)?.[key]); +} + +function stringFromUnknown(value: unknown): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +} + +function stringField(record: AppDataRecord, key: string): string | undefined { + return stringFromUnknown(record[key]); +} + +function recordField(record: AppDataRecord, key: string): AppDataRecord | undefined { + return asRecord(record[key]); +} + +function numberField(record: AppDataRecord, key: string): number | undefined { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function integerField(record: AppDataRecord, key: string): number | undefined { + const value = numberField(record, key); + return typeof value === 'number' ? Math.floor(value) : undefined; +} + +function booleanField(record: AppDataRecord, key: string): boolean | undefined { + const value = record[key]; + if (typeof value === 'boolean') return value; + return undefined; +} + +function priorityRank(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value)); + if (typeof value === 'string') { + const normalized = value.trim().toUpperCase(); + const match = /^P([0-4])$/.exec(normalized); + if (match) return Number(match[1]); + const numeric = Number(normalized); + if (Number.isFinite(numeric)) return Math.max(0, Math.floor(numeric)); + } + return 0; +} + +function scopedCodeFactory(prefix: string) { + const usedByScope = new Map>(); + return (scope: string, candidate: string | undefined, index: number): string => { + const used = usedByScope.get(scope) ?? new Set(); + usedByScope.set(scope, used); + const base = candidate?.trim() || `${prefix}-${String(index + 1).padStart(3, '0')}`; + if (!used.has(base)) { + used.add(base); + return base; + } + let suffix = 2; + while (used.has(`${base}-${suffix}`)) suffix++; + const next = `${base}-${suffix}`; + used.add(next); + return next; + }; +} + +function inferProjectIdForVersion(version: AppDataRecord, projects: AppDataRecord[]): string | undefined { + const explicitProjectId = stringField(version, 'projectId'); + if (explicitProjectId) return explicitProjectId; + const versionName = stringField(version, 'name')?.toLowerCase(); + if (!versionName) return undefined; + const project = projects.find((item) => { + const projectName = stringField(item, 'name')?.toLowerCase(); + return Boolean(projectName && versionName.startsWith(projectName)); + }); + return project ? stringField(project, 'id') : undefined; +} + +function resolveVersionContext( + row: AppDataRecord, + requirementById: Map, + versionById: Map, +): SourceContext { + const requirementId = stringField(row, 'requirementId'); + const requirement = requirementId ? requirementById.get(requirementId) : undefined; + const versionId = stringField(row, 'versionId') ?? requirement?.versionId; + const version = versionId ? versionById.get(versionId) : undefined; + return { + versionId, + productId: stringField(row, 'productId') ?? requirement?.productId ?? version?.productId, + projectId: stringField(row, 'projectId') ?? requirement?.projectId ?? version?.projectId, + }; +} + +function resolveSourceContext( + sourceType: string, + sourceId: string, + maps: { + devTaskById: Map; + testCaseById: Map; + bugById: Map; + versionPlanById: Map; + }, +): SourceContext { + if (sourceType === 'dev_task') return contextFromRow(maps.devTaskById.get(sourceId)); + if (sourceType === 'test_case') return contextFromRow(maps.testCaseById.get(sourceId)); + if (sourceType === 'bug') return contextFromRow(maps.bugById.get(sourceId)); + if (sourceType === 'version_plan') return contextFromRow(maps.versionPlanById.get(sourceId)); + return {}; +} + +function resolveWorklogSource( + sourceId: string, + maps: { + devTaskById: Map; + testCaseById: Map; + bugById: Map; + }, +): { sourceType: string; context: SourceContext } { + const devTask = maps.devTaskById.get(sourceId); + if (devTask) return { sourceType: 'dev_task', context: contextFromRow(devTask) }; + const testCase = maps.testCaseById.get(sourceId); + if (testCase) return { sourceType: 'test_case', context: contextFromRow(testCase) }; + const bug = maps.bugById.get(sourceId); + if (bug) return { sourceType: 'bug', context: contextFromRow(bug) }; + return { sourceType: 'legacy_task', context: {} }; +} + +function contextFromRow(row: SourceContext | undefined): SourceContext { + if (!row) return {}; + return { + versionId: row.versionId, + productId: row.productId, + projectId: row.projectId, + }; +} + +function sliceDate(value?: string): string | undefined { + if (!value) return undefined; + return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : undefined; +} + +function compareIso(a?: string, b?: string): number { + const aTime = a ? new Date(a).getTime() : 0; + const bTime = b ? new Date(b).getTime() : 0; + const safeA = Number.isFinite(aTime) ? aTime : 0; + const safeB = Number.isFinite(bTime) ? bTime : 0; + return safeA - safeB; +} + +function buildRiskSignature(snapshot: AppDataRecord, versionId: string, date: string): string { + return [ + versionId, + date, + clampScore(integerField(snapshot, 'riskScore') ?? 0), + stringField(snapshot, 'riskLevel') ?? 'on_track', + normalizeForecastDate(stringField(snapshot, 'forecastReleaseDate')), + integerField(snapshot, 'openBugCount') ?? 0, + integerField(snapshot, 'criticalBugCount') ?? 0, + integerField(snapshot, 'failedTestCount') ?? 0, + integerField(snapshot, 'blockedCount') ?? 0, + integerField(snapshot, 'silentRiskCount') ?? 0, + clampScore(integerField(snapshot, 'confidence') ?? 0), + ].join('|'); +} + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} + +function normalizeForecastDate(value?: string): string { + if (!value) return ''; + if (/^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10); + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) return value; + return new Date(time).toISOString().slice(0, 10); +} diff --git a/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts b/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts new file mode 100644 index 0000000..59e67e4 --- /dev/null +++ b/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts @@ -0,0 +1,73 @@ +import { APP_DATA_KEYS } from '../data/data-keys'; +import { AppDataV22MigrationService } from './app-data-v22.migration.service'; + +describe('AppDataV22MigrationService', () => { + it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => { + const findMany = jest.fn().mockResolvedValue([ + { + key: 'products-overview', + value: [ + { + id: 'product-1', + name: 'FTB', + projects: [{ id: 'project-1', name: 'CRM', description: '', createdAt: '2026-01-01T00:00:00.000Z' }], + versions: [{ id: 'version-1', name: 'CRM V1.0', createdAt: '2026-01-01T00:00:00.000Z' }], + }, + ], + }, + { + key: 'requirements', + value: { + requirements: [ + { + id: 'req-1', + code: 'REQ-001', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + title: 'Customer import', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }, + { + key: 'dev-tasks', + value: [ + { + id: 'dev-1', + taskNo: 'DEV-001', + requirementId: 'req-1', + title: 'Build import UI', + status: 'todo', + isBlocked: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + ]); + const createMany = jest.fn(); + const prisma = { appData: { findMany }, requirement: { createMany } }; + const service = new AppDataV22MigrationService(prisma as any); + + const preview = await service.preview(); + + expect(findMany).toHaveBeenCalledWith({ + where: { key: { in: APP_DATA_KEYS } }, + select: { key: true, value: true }, + }); + expect(createMany).not.toHaveBeenCalled(); + expect(preview.readyToImport).toBe(true); + expect(preview.counts).toEqual( + expect.objectContaining({ + products: 1, + projects: 1, + versions: 1, + requirements: 1, + devTasks: 1, + }), + ); + expect(preview.skipped).toEqual([]); + }); +}); diff --git a/apps/server/src/modules/migration/app-data-v22.migration.service.ts b/apps/server/src/modules/migration/app-data-v22.migration.service.ts new file mode 100644 index 0000000..4dcbae2 --- /dev/null +++ b/apps/server/src/modules/migration/app-data-v22.migration.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { APP_DATA_KEYS } from '../data/data-keys'; +import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper'; + +const COUNT_KEYS = [ + 'products', + 'projects', + 'versions', + 'requirements', + 'taskCategories', + 'versionPlans', + 'devTasks', + 'testCases', + 'bugs', + 'workActivities', + 'taskWorklogs', + 'overtimeRecords', + 'xiaobaoRiskSnapshots', + 'xiaobaoRiskInsights', + 'xiaobaoRiskSummaries', + 'aiLogs', +] as const; + +type CountKey = (typeof COUNT_KEYS)[number]; + +export interface AppDataV22MigrationPreview { + readyToImport: boolean; + counts: Record; + skipped: V22MappedRows['skipped']; +} + +@Injectable() +export class AppDataV22MigrationService { + constructor(private readonly prisma: PrismaService) {} + + async loadSnapshot(): Promise> { + const rows = await this.prisma.appData.findMany({ + where: { key: { in: [...APP_DATA_KEYS] } }, + select: { key: true, value: true }, + }); + return Object.fromEntries(rows.map((row) => [row.key, row.value])); + } + + mapSnapshot(snapshot: Record): V22MappedRows { + return mapAppDataToV22Rows(snapshot); + } + + async preview(): Promise { + const mapped = this.mapSnapshot(await this.loadSnapshot()); + return { + readyToImport: mapped.skipped.length === 0, + counts: countRows(mapped), + skipped: mapped.skipped, + }; + } +} + +function countRows(mapped: V22MappedRows): Record { + return Object.fromEntries(COUNT_KEYS.map((key) => [key, mapped[key].length])) as Record; +} diff --git a/apps/server/src/modules/migration/migration.module.ts b/apps/server/src/modules/migration/migration.module.ts new file mode 100644 index 0000000..b1d48b2 --- /dev/null +++ b/apps/server/src/modules/migration/migration.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AppDataV22MigrationService } from './app-data-v22.migration.service'; + +@Module({ + providers: [AppDataV22MigrationService], + exports: [AppDataV22MigrationService], +}) +export class MigrationModule {} diff --git a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts index 8952705..c83c4b9 100644 --- a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts +++ b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts @@ -1,6 +1,10 @@ import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator'; export class CreateRequirementDto { + @IsString() + @IsOptional() + code?: string; + @IsString() @IsNotEmpty() title!: string; diff --git a/apps/server/src/modules/requirement/requirement.service.spec.ts b/apps/server/src/modules/requirement/requirement.service.spec.ts new file mode 100644 index 0000000..ecd88c9 --- /dev/null +++ b/apps/server/src/modules/requirement/requirement.service.spec.ts @@ -0,0 +1,72 @@ +import { RequirementService } from './requirement.service'; + +describe('RequirementService with V2.2 composite requirement key', () => { + const makeService = () => { + const prisma = { + requirement: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + service: new RequirementService(prisma as any), + }; + }; + + it('creates requirements with a partition-key-scoped business code', async () => { + const { prisma, service } = makeService(); + prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' }); + + await service.create('product-1', { + code: 'REQ-001', + title: 'Payment', + creatorId: 'user-1', + }); + + expect(prisma.requirement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + productId: 'product-1', + code: 'REQ-001', + title: 'Payment', + }), + }); + }); + + it('updates requirements by id plus product id', async () => { + const { prisma, service } = makeService(); + prisma.requirement.findFirst.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: 'draft', + }); + prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' }); + + await service.update('product-1', 'req-1', { title: 'Payment v2' }); + + expect(prisma.requirement.update).toHaveBeenCalledWith({ + where: { id_productId: { id: 'req-1', productId: 'product-1' } }, + data: { title: 'Payment v2' }, + }); + }); + + it('deletes requirements by id plus product id', async () => { + const { prisma, service } = makeService(); + prisma.requirement.findFirst.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: 'draft', + }); + prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' }); + + await service.remove('product-1', 'req-1'); + + expect(prisma.requirement.delete).toHaveBeenCalledWith({ + where: { id_productId: { id: 'req-1', productId: 'product-1' } }, + }); + }); +}); diff --git a/apps/server/src/modules/requirement/requirement.service.ts b/apps/server/src/modules/requirement/requirement.service.ts index 1a4235b..fbe65ab 100644 --- a/apps/server/src/modules/requirement/requirement.service.ts +++ b/apps/server/src/modules/requirement/requirement.service.ts @@ -12,6 +12,13 @@ const VALID_TRANSITIONS: Record = { [RequirementStatus.DELIVERED]: [], }; +function createFallbackRequirementCode() { + return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random() + .toString(36) + .slice(2, 6) + .toUpperCase()}`; +} + @Injectable() export class RequirementService { constructor(private prisma: PrismaService) {} @@ -20,6 +27,7 @@ export class RequirementService { return this.prisma.requirement.create({ data: { productId, + code: dto.code?.trim() || createFallbackRequirementCode(), title: dto.title, description: dto.description || '', priority: dto.priority ?? 0, @@ -51,8 +59,9 @@ export class RequirementService { async update(productId: string, id: string, dto: UpdateRequirementDto) { await this.findOne(productId, id); return this.prisma.requirement.update({ - where: { id }, + where: { id_productId: { id, productId } }, data: { + ...(dto.code !== undefined && { code: dto.code }), ...(dto.title !== undefined && { title: dto.title }), ...(dto.description !== undefined && { description: dto.description }), ...(dto.priority !== undefined && { priority: dto.priority }), @@ -69,13 +78,13 @@ export class RequirementService { ); } return this.prisma.requirement.update({ - where: { id }, + where: { id_productId: { id, productId } }, data: { status: newStatus }, }); } async remove(productId: string, id: string) { await this.findOne(productId, id); - return this.prisma.requirement.delete({ where: { id } }); + return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } }); } -} \ No newline at end of file +} diff --git a/docs/architecture.md b/docs/architecture.md index fbf2223..6460cf0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -220,3 +220,26 @@ Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-mana AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent. Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet. +## V2.2 Partitioned Domain Data Layer (2026-07-03) + +V2.2 starts the move from AppData JSON documents to relation tables for high-volume domains. The first database foundation is partitioned from the start so future growth does not require a disruptive rewrite of primary keys, unique constraints, and foreign-key references. + +High-growth business tables use fixed hash partitions: + +- `requirements`: hash partitioned by `product_id`; primary key is `(id, product_id)`. +- `dev_tasks`, `test_cases`, and `bugs`: hash partitioned by `version_id`; primary key is `(id, version_id)`. + +Append-only evidence and history tables use range partitions by `created_at`: + +- `work_activities` +- `task_worklogs` +- `overtime_records` +- `xiaobao_risk_snapshots` +- `xiaobao_risk_insights` +- `ai_logs` + +Partitioned tables must include the partition key in every primary key and business unique constraint. Cross-table references to partitioned tables use composite foreign keys when the referencing row naturally carries the partition key, for example `(requirement_id, requirement_product_id)` and `(test_case_id, test_case_version_id)`. Polymorphic activity references remain logical references. + +Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis. + +The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled. diff --git a/docs/decisions.md b/docs/decisions.md index 067f24e..3d0e8b8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -516,3 +516,16 @@ - 人工新建任务表单继续使用任务类型字典作为可选项,但这个字典会随着 AI 草案采纳自动扩展。 **理由**:人工表单的任务类型是录入辅助,不应该成为 AI 拆解边界。但任务类型字典也不能被单个原型的业务对象污染。`taskTypeName` 只承载可复用分类,具体功能点放在 `title` 和 `description`;采纳时有门槛地补字典,让后续筛选、统计和人工创建复用真正稳定的类型,同时避免 AI 直接写数据库 `categoryId`。 +## 42. V2.2 高增长业务表从一开始采用分区表 + +**问题**:需求池、开发任务、测试用例、BUG、工作活动和小宝快照会按年持续增长。若先用普通表,后续再切分区表,不只是搬旧数据,还会牵动主键、唯一约束、外键、迁移窗口和回滚方案。 + +**决策**: +- `requirements` 按 `product_id` 做固定 HASH 分区,主键为 `(id, product_id)`。 +- `dev_tasks` / `test_cases` / `bugs` 按 `version_id` 做固定 HASH 分区,主键为 `(id, version_id)`。 +- `work_activities` / `task_worklogs` / `overtime_records` / `xiaobao_risk_snapshots` / `xiaobao_risk_insights` / `ai_logs` 按 `created_at` 做 RANGE 分区并保留 default partition。 +- 所有分区表主键和业务唯一约束必须包含分区键,例如 `(version_id, code)`。 +- 引用分区表时优先使用复合外键,例如 `(requirement_id, requirement_product_id)`、`(test_case_id, test_case_version_id)`;多态活动记录使用逻辑外键。 +- `xiaobao_risk_summaries` 不分区,保持 `version_id` 单行汇总;历史趋势进入分区快照表。 + +**理由**:把分区键纳入主键和唯一约束是 PostgreSQL 分区表的结构性要求。提前做这件事,可以避免客户数据变大后再重塑主键和外键。固定 HASH 分区避免每个项目/版本单独建分区的维护负担;RANGE 分区只用于天然追加、按时间维护的历史数据。 diff --git a/docs/roadmap.md b/docs/roadmap.md index b346680..8ccc98b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,13 @@ ### 已完成(按时间倒序) +**2026-07-03** +- V2.2 partitioned domain schema foundation added: `requirements` uses HASH partitioning by `product_id`; `dev_tasks`, `test_cases`, and `bugs` use HASH partitioning by `version_id`. +- Partitioned table primary keys and business unique constraints now include partition keys, for example `(id, version_id)` and `(version_id, code)`. +- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots. +- Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key. +- AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled. + **2026-07-02** - `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新 - stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖