From ad36ffda17a8dd45499913ac70bce748b0fba7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2.7):=20=E5=BB=BA=E7=AB=8B=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E6=B2=BB=E7=90=86=E5=9F=BA=E7=A1=80=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 85 +++++++++++++++++ apps/server/prisma/schema.prisma | 93 ++++++++++++++++--- .../prisma/v27-enterprise-schema.spec.ts | 50 ++++++++++ .../src/common/audit/audit.service.spec.ts | 51 ++++++++++ apps/server/src/common/audit/audit.service.ts | 43 +++++++++ .../src/common/rbac/rbac.service.spec.ts | 70 ++++++++++++++ apps/server/src/common/rbac/rbac.service.ts | 63 +++++++++++++ pnpm-workspace.yaml | 5 + 8 files changed, 448 insertions(+), 12 deletions(-) create mode 100644 apps/server/prisma/migrations/20260708000000_v27_enterprise_collab_governance/migration.sql create mode 100644 apps/server/prisma/v27-enterprise-schema.spec.ts create mode 100644 apps/server/src/common/audit/audit.service.spec.ts create mode 100644 apps/server/src/common/audit/audit.service.ts create mode 100644 apps/server/src/common/rbac/rbac.service.spec.ts create mode 100644 apps/server/src/common/rbac/rbac.service.ts diff --git a/apps/server/prisma/migrations/20260708000000_v27_enterprise_collab_governance/migration.sql b/apps/server/prisma/migrations/20260708000000_v27_enterprise_collab_governance/migration.sql new file mode 100644 index 0000000..5ef4a93 --- /dev/null +++ b/apps/server/prisma/migrations/20260708000000_v27_enterprise_collab_governance/migration.sql @@ -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"); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index c9060f0..fd41054 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -138,7 +138,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") } @@ -156,30 +155,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 diff --git a/apps/server/prisma/v27-enterprise-schema.spec.ts b/apps/server/prisma/v27-enterprise-schema.spec.ts new file mode 100644 index 0000000..b2bf989 --- /dev/null +++ b/apps/server/prisma/v27-enterprise-schema.spec.ts @@ -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/); + }); +}); diff --git a/apps/server/src/common/audit/audit.service.spec.ts b/apps/server/src/common/audit/audit.service.spec.ts new file mode 100644 index 0000000..6908396 --- /dev/null +++ b/apps/server/src/common/audit/audit.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/common/audit/audit.service.ts b/apps/server/src/common/audit/audit.service.ts new file mode 100644 index 0000000..be5aec1 --- /dev/null +++ b/apps/server/src/common/audit/audit.service.ts @@ -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 { + 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}`); + } + } +} diff --git a/apps/server/src/common/rbac/rbac.service.spec.ts b/apps/server/src/common/rbac/rbac.service.spec.ts new file mode 100644 index 0000000..ed0f7a8 --- /dev/null +++ b/apps/server/src/common/rbac/rbac.service.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/common/rbac/rbac.service.ts b/apps/server/src/common/rbac/rbac.service.ts new file mode 100644 index 0000000..151877c --- /dev/null +++ b/apps/server/src/common/rbac/rbac.service.ts @@ -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 = { + owner: 4, + admin: 3, + member: 2, + viewer: 1, +}; + +@Injectable() +export class RbacService { + constructor(private readonly prisma: PrismaService) {} + + async assertProjectRole(input: ProjectRoleAssertion): Promise { + 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; +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff5faa..4f31554 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,8 @@ packages: - "apps/*" - "packages/*" +allowBuilds: + '@nestjs/core': true + '@prisma/client': true + '@prisma/engines': true + prisma: true