merge: 集成V2.7 企业协作与管理治理

# Conflicts:
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	apps/web/lib/permissions.ts
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
This commit is contained in:
2026-07-08 18:10:08 +08:00
60 changed files with 3219 additions and 19 deletions

View File

@@ -0,0 +1,85 @@
ALTER TABLE "comments" DROP CONSTRAINT IF EXISTS "comments_task_id_fkey";
DROP TABLE IF EXISTS "comments";
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" TEXT NOT NULL,
"entity_version_id" TEXT,
"product_id" TEXT,
"project_id" TEXT,
"version_id" TEXT,
"author_id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"mentioned_member_ids" JSONB NOT NULL DEFAULT '[]',
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "comments_entity_created_idx" ON "comments"("entity_type", "entity_id", "created_at");
CREATE INDEX "comments_author_created_idx" ON "comments"("author_id", "created_at");
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX IF NOT EXISTS "project_members_user_role_idx" ON "project_members"("user_id", "role");
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"recipient_id" TEXT NOT NULL,
"actor_id" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL DEFAULT '',
"resource_type" TEXT NOT NULL,
"resource_id" TEXT NOT NULL,
"resource_version_id" TEXT,
"product_id" TEXT,
"project_id" TEXT,
"version_id" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"read_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "notifications_recipient_read_created_idx" ON "notifications"("recipient_id", "read_at", "created_at");
CREATE INDEX "notifications_resource_idx" ON "notifications"("resource_type", "resource_id");
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"actor_id" TEXT,
"action" TEXT NOT NULL,
"resource_type" TEXT NOT NULL,
"resource_id" TEXT NOT NULL,
"product_id" TEXT,
"project_id" TEXT,
"version_id" TEXT,
"before" JSONB NOT NULL DEFAULT '{}',
"after" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "audit_logs_resource_created_idx" ON "audit_logs"("resource_type", "resource_id", "created_at");
CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs"("actor_id", "created_at");
CREATE TABLE "governance_dictionaries" (
"id" TEXT NOT NULL,
"scope" TEXT NOT NULL DEFAULT 'global',
"kind" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"group" TEXT,
"value" JSONB NOT NULL DEFAULT '{}',
"is_system" BOOLEAN NOT NULL DEFAULT false,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "governance_dictionaries_pkey" PRIMARY KEY ("id"),
CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE ("scope", "kind", "name")
);
CREATE INDEX "governance_dictionaries_kind_deleted_idx" ON "governance_dictionaries"("kind", "deleted_at");

View File

@@ -152,7 +152,6 @@ model Task {
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
watchers TaskWatcher[]
comments Comment[]
@@map("tasks")
}
@@ -170,30 +169,100 @@ model TaskWatcher {
}
model Comment {
id String @id @default(cuid())
taskId String @map("task_id")
authorId String @map("author_id")
content String
createdAt DateTime @default(now()) @map("created_at")
task Task @relation(fields: [taskId], references: [id])
id String @id @default(cuid())
entityType String @map("entity_type")
entityId String @map("entity_id")
entityVersionId String? @map("entity_version_id")
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
authorId String @map("author_id")
content String
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
@@index([authorId, createdAt], name: "comments_author_created_idx")
@@map("comments")
}
model ProjectMember {
id String @id @default(cuid())
projectId String @map("project_id")
userId String @map("user_id")
role String @default("member")
id String @id @default(cuid())
projectId String @map("project_id")
userId String @map("user_id")
role String @default("member")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([projectId, userId])
@@index([userId, role], name: "project_members_user_role_idx")
@@map("project_members")
}
model Notification {
id String @id @default(cuid())
recipientId String @map("recipient_id")
actorId String? @map("actor_id")
type String
title String
body String @default("")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
resourceVersionId String? @map("resource_version_id")
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
metadata Json @default("{}")
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([recipientId, readAt, createdAt], name: "notifications_recipient_read_created_idx")
@@index([resourceType, resourceId], name: "notifications_resource_idx")
@@map("notifications")
}
model AuditLog {
id String @id @default(cuid())
actorId String? @map("actor_id")
action String
resourceType String @map("resource_type")
resourceId String @map("resource_id")
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
before Json @default("{}")
after Json @default("{}")
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@index([resourceType, resourceId, createdAt], name: "audit_logs_resource_created_idx")
@@index([actorId, createdAt], name: "audit_logs_actor_created_idx")
@@map("audit_logs")
}
model GovernanceDictionary {
id String @id @default(cuid())
scope String @default("global")
kind String
name String
code String?
group String?
value Json @default("{}")
isSystem Boolean @default(false) @map("is_system")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([scope, kind, name])
@@index([kind, deletedAt], name: "governance_dictionaries_kind_deleted_idx")
@@map("governance_dictionaries")
}
model TaskCategory {
id String @id @default(cuid())
name String

View File

@@ -0,0 +1,50 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const migrationPath = join(
process.cwd(),
'prisma',
'migrations',
'20260708000000_v27_enterprise_collab_governance',
'migration.sql',
);
function readMigrationSql() {
if (!existsSync(migrationPath)) {
throw new Error(`Missing V2.7 migration: ${migrationPath}`);
}
return readFileSync(migrationPath, 'utf8');
}
describe('V2.7 enterprise collaboration schema migration', () => {
it('creates notification records for assignment, mention, risk alert, and overdue events', () => {
const sql = readMigrationSql();
expect(sql).toMatch(/CREATE TABLE "notifications"/);
expect(sql).toMatch(/"type" TEXT NOT NULL/);
expect(sql).toMatch(/"recipient_id" TEXT NOT NULL/);
expect(sql).toMatch(/"read_at" TIMESTAMP\(3\)/);
expect(sql).toMatch(/CREATE INDEX "notifications_recipient_read_created_idx"/);
});
it('creates polymorphic comments with mention metadata', () => {
const sql = readMigrationSql();
expect(sql).toMatch(/CREATE TABLE "comments"/);
expect(sql).toMatch(/"entity_type" TEXT NOT NULL/);
expect(sql).toMatch(/"entity_id" TEXT NOT NULL/);
expect(sql).toMatch(/"mentioned_member_ids" JSONB NOT NULL DEFAULT '\[\]'/);
expect(sql).toMatch(/CREATE INDEX "comments_entity_created_idx"/);
});
it('adds audit logs and governance dictionaries with soft-delete support', () => {
const sql = readMigrationSql();
expect(sql).toMatch(/CREATE TABLE "audit_logs"/);
expect(sql).toMatch(/"before" JSONB NOT NULL DEFAULT '\{\}'/);
expect(sql).toMatch(/"after" JSONB NOT NULL DEFAULT '\{\}'/);
expect(sql).toMatch(/CREATE TABLE "governance_dictionaries"/);
expect(sql).toMatch(/"deleted_at" TIMESTAMP\(3\)/);
expect(sql).toMatch(/CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE/);
});
});