feat(v2.7): 建立协作治理基础合同

This commit is contained in:
2026-07-08 16:09:45 +08:00
parent 27cc1badc7
commit ad36ffda17
8 changed files with 448 additions and 12 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

@@ -138,7 +138,6 @@ model Task {
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id]) assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
creator User @relation("TaskCreator", fields: [creatorId], references: [id]) creator User @relation("TaskCreator", fields: [creatorId], references: [id])
watchers TaskWatcher[] watchers TaskWatcher[]
comments Comment[]
@@map("tasks") @@map("tasks")
} }
@@ -156,30 +155,100 @@ model TaskWatcher {
} }
model Comment { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
taskId String @map("task_id") entityType String @map("entity_type")
authorId String @map("author_id") entityId String @map("entity_id")
content String entityVersionId String? @map("entity_version_id")
createdAt DateTime @default(now()) @map("created_at") productId String? @map("product_id")
projectId String? @map("project_id")
task Task @relation(fields: [taskId], references: [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") @@map("comments")
} }
model ProjectMember { model ProjectMember {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String @map("project_id") projectId String @map("project_id")
userId String @map("user_id") userId String @map("user_id")
role String @default("member") role String @default("member")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id]) project Project @relation(fields: [projectId], references: [id])
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
@@unique([projectId, userId]) @@unique([projectId, userId])
@@index([userId, role], name: "project_members_user_role_idx")
@@map("project_members") @@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 { model TaskCategory {
id String @id @default(cuid()) id String @id @default(cuid())
name String 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/);
});
});

View File

@@ -0,0 +1,51 @@
import { AuditService } from './audit.service';
describe('AuditService V2.7 adapter contract', () => {
it('writes an append-only audit event with actor and resource scope', async () => {
const prisma = {
auditLog: {
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
},
};
const service = new AuditService(prisma as any);
await service.record({
actorId: 'm-admin',
action: 'project_member.role_changed',
resourceType: 'project_member',
resourceId: 'pm-1',
projectId: 'project-1',
before: { role: 'member' },
after: { role: 'admin' },
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
actorId: 'm-admin',
action: 'project_member.role_changed',
resourceType: 'project_member',
resourceId: 'pm-1',
projectId: 'project-1',
before: { role: 'member' },
after: { role: 'admin' },
}),
});
});
it('does not fail the business action when audit storage is unavailable', async () => {
const prisma = {
auditLog: {
create: jest.fn().mockRejectedValue(new Error('database unavailable')),
},
};
const service = new AuditService(prisma as any);
(service as any).logger.warn = jest.fn();
await expect(service.record({
actorId: 'm-admin',
action: 'comment.created',
resourceType: 'comment',
resourceId: 'comment-1',
})).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface AuditEventInput {
actorId?: string | null;
action: string;
resourceType: string;
resourceId: string;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
before?: unknown;
after?: unknown;
metadata?: unknown;
}
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(private readonly prisma: PrismaService) {}
async record(input: AuditEventInput): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
actorId: input.actorId ?? null,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
before: input.before ?? {},
after: input.after ?? {},
metadata: input.metadata ?? {},
},
});
} catch (error: any) {
this.logger.warn(`Audit write failed for ${input.action}: ${error?.message ?? error}`);
}
}
}

View File

@@ -0,0 +1,70 @@
import { ForbiddenException } from '@nestjs/common';
import { RbacService } from './rbac.service';
describe('RbacService V2.7 adapter contract', () => {
const makeService = () => {
const prisma = {
projectMember: {
findUnique: jest.fn(),
},
};
return { prisma, service: new RbacService(prisma as any) };
};
it('allows a system admin actor without querying project membership', async () => {
const { prisma, service } = makeService();
await expect(service.assertProjectRole({
actorId: 'm-admin',
projectId: 'project-1',
allowedRoles: ['owner'],
permissions: ['*'],
})).resolves.toEqual({
actorId: 'm-admin',
projectId: 'project-1',
role: 'owner',
via: 'system',
});
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
});
it('allows project owners to perform admin-scoped actions', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-1',
projectId: 'project-1',
userId: 'm-owner',
role: 'owner',
});
await expect(service.assertProjectRole({
actorId: 'm-owner',
projectId: 'project-1',
allowedRoles: ['admin'],
permissions: [],
})).resolves.toEqual({
actorId: 'm-owner',
projectId: 'project-1',
role: 'owner',
via: 'project_member',
});
});
it('rejects actors below the required project role', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-1',
projectId: 'project-1',
userId: 'm-viewer',
role: 'viewer',
});
await expect(service.assertProjectRole({
actorId: 'm-viewer',
projectId: 'project-1',
allowedRoles: ['member'],
permissions: [],
})).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -0,0 +1,63 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export type ProjectGovernanceRole = 'owner' | 'admin' | 'member' | 'viewer';
export interface ProjectRoleAssertion {
actorId?: string;
projectId?: string | null;
allowedRoles: ProjectGovernanceRole[];
permissions?: string[];
}
export interface ProjectRoleDecision {
actorId: string;
projectId: string;
role: ProjectGovernanceRole;
via: 'system' | 'project_member';
}
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
owner: 4,
admin: 3,
member: 2,
viewer: 1,
};
@Injectable()
export class RbacService {
constructor(private readonly prisma: PrismaService) {}
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
const actorId = input.actorId?.trim();
const projectId = input.projectId?.trim();
if (!actorId || !projectId) {
throw new ForbiddenException('Missing actor or project scope');
}
if (input.permissions?.includes('*')) {
return { actorId, projectId, role: 'owner', via: 'system' };
}
const membership = await this.prisma.projectMember.findUnique({
where: { projectId_userId: { projectId, userId: actorId } },
});
const role = normalizeProjectRole(membership?.role);
if (!role || !hasRequiredRole(role, input.allowedRoles)) {
throw new ForbiddenException('Insufficient project role');
}
return { actorId, projectId, role, via: 'project_member' };
}
}
export function normalizeProjectRole(role: string | null | undefined): ProjectGovernanceRole | null {
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'viewer') return role;
return null;
}
export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: ProjectGovernanceRole[]): boolean {
if (allowedRoles.length === 0) return false;
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
return ROLE_RANK[role] >= minimumRank;
}

View File

@@ -1,3 +1,8 @@
packages: packages:
- "apps/*" - "apps/*"
- "packages/*" - "packages/*"
allowBuilds:
'@nestjs/core': true
'@prisma/client': true
'@prisma/engines': true
prisma: true