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 513f90d..4fa5ee6 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -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 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/app.module.ts b/apps/server/src/app.module.ts index 121dfe8..1ad6726 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -26,6 +26,11 @@ import { ConsistencyModule } from './modules/consistency/consistency.module'; import { JobsModule } from './modules/jobs/jobs.module'; import { XiaobaoModule } from './modules/xiaobao/xiaobao.module'; import { OpsModule } from './modules/ops/ops.module'; +import { NotificationModule } from './modules/notification/notification.module'; +import { CommentModule } from './modules/comment/comment.module'; +import { ProjectMemberModule } from './modules/project-member/project-member.module'; +import { ManagementModule } from './modules/management/management.module'; +import { GovernanceModule } from './modules/governance/governance.module'; @Module({ imports: [ @@ -53,6 +58,11 @@ import { OpsModule } from './modules/ops/ops.module'; JobsModule, XiaobaoModule, OpsModule, + NotificationModule, + CommentModule, + ProjectMemberModule, + ManagementModule, + GovernanceModule, AiModule, ], controllers: [], 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/common-domain.module.ts b/apps/server/src/common/common-domain.module.ts new file mode 100644 index 0000000..96781c0 --- /dev/null +++ b/apps/server/src/common/common-domain.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AuditService } from './audit/audit.service'; +import { RbacService } from './rbac/rbac.service'; + +@Module({ + providers: [AuditService, RbacService], + exports: [AuditService, RbacService], +}) +export class CommonDomainModule {} 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..5afadbb --- /dev/null +++ b/apps/server/src/common/rbac/rbac.service.spec.ts @@ -0,0 +1,93 @@ +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 actors with an explicit global permission', async () => { + const { service } = makeService(); + + await expect(service.assertGlobalPermission({ + actorId: 'm-pm', + permissions: ['management:view'], + requiredPermissions: ['management:view'], + })).resolves.toEqual({ + actorId: 'm-pm', + via: 'permission', + }); + }); + + it('rejects actors without the required global permission', async () => { + const { service } = makeService(); + + await expect(service.assertGlobalPermission({ + actorId: 'm-dev', + permissions: ['project:view'], + requiredPermissions: ['governance:manage'], + })).rejects.toBeInstanceOf(ForbiddenException); + }); + + 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..9f91674 --- /dev/null +++ b/apps/server/src/common/rbac/rbac.service.ts @@ -0,0 +1,94 @@ +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 GlobalPermissionAssertion { + actorId?: string; + permissions?: string[]; + requiredPermissions: string[]; +} + +export interface ProjectRoleDecision { + actorId: string; + projectId: string; + role: ProjectGovernanceRole; + via: 'system' | 'project_member'; +} + +export interface GlobalPermissionDecision { + actorId: string; + via: 'system' | 'permission'; +} + +const ROLE_RANK: Record = { + owner: 4, + admin: 3, + member: 2, + viewer: 1, +}; + +@Injectable() +export class RbacService { + constructor(private readonly prisma: PrismaService) {} + + async assertGlobalPermission(input: GlobalPermissionAssertion): Promise { + const actorId = input.actorId?.trim(); + if (!actorId) { + throw new ForbiddenException('Missing actor scope'); + } + if (input.permissions?.includes('*')) { + return { actorId, via: 'system' }; + } + if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) { + return { actorId, via: 'permission' }; + } + throw new ForbiddenException('Insufficient global permission'); + } + + 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; +} + +export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean { + if (requiredPermissions.length === 0) return false; + const granted = new Set(permissions); + return requiredPermissions.some((permission) => granted.has(permission)); +} diff --git a/apps/server/src/modules/comment/comment.controller.ts b/apps/server/src/modules/comment/comment.controller.ts new file mode 100644 index 0000000..6940582 --- /dev/null +++ b/apps/server/src/modules/comment/comment.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { CommentService, type CommentEntityType } from './comment.service'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { DeleteCommentDto } from './dto/delete-comment.dto'; + +@Controller('comments') +export class CommentController { + constructor(private readonly commentService: CommentService) {} + + @Get() + list(@Query('entityType') entityType: CommentEntityType, @Query('entityId') entityId: string) { + return this.commentService.list(entityType, entityId); + } + + @Post() + create(@Body() dto: CreateCommentDto) { + return this.commentService.create(dto); + } + + @Delete(':id') + remove(@Param('id') id: string, @Body() dto: DeleteCommentDto) { + return this.commentService.remove(id, dto.actorId); + } +} diff --git a/apps/server/src/modules/comment/comment.module.ts b/apps/server/src/modules/comment/comment.module.ts new file mode 100644 index 0000000..a97cc7e --- /dev/null +++ b/apps/server/src/modules/comment/comment.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CommonDomainModule } from '../../common/common-domain.module'; +import { NotificationModule } from '../notification/notification.module'; +import { CommentController } from './comment.controller'; +import { CommentService } from './comment.service'; + +@Module({ + imports: [CommonDomainModule, NotificationModule], + controllers: [CommentController], + providers: [CommentService], + exports: [CommentService], +}) +export class CommentModule {} diff --git a/apps/server/src/modules/comment/comment.service.spec.ts b/apps/server/src/modules/comment/comment.service.spec.ts new file mode 100644 index 0000000..bc87a7d --- /dev/null +++ b/apps/server/src/modules/comment/comment.service.spec.ts @@ -0,0 +1,112 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { NotificationService } from '../notification/notification.service'; +import { CommentService } from './comment.service'; + +describe('CommentService', () => { + const makeService = () => { + const prisma = { + comment: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + projectMember: { + findMany: jest.fn(), + }, + }; + const notifications = { + createMany: jest.fn(), + } as unknown as NotificationService; + const audit = { + record: jest.fn(), + } as unknown as AuditService; + return { + prisma, + notifications, + audit, + service: new CommentService(prisma as any, notifications, audit), + }; + }; + + it('creates a polymorphic comment, extracts @mentions, notifies mentioned members, and writes audit', async () => { + const { prisma, notifications, audit, service } = makeService(); + prisma.projectMember.findMany.mockResolvedValue([ + { userId: 'm-alice', user: { id: 'm-alice', name: 'Alice' } }, + { userId: 'm-bob', user: { id: 'm-bob', name: 'Bob' } }, + ]); + prisma.comment.create.mockResolvedValue({ + id: 'comment-1', + entityType: 'dev_task', + entityId: 'task-1', + mentionedMemberIds: ['m-alice', 'm-bob'], + }); + + const result = await service.create({ + actorId: 'm-author', + entityType: 'dev_task', + entityId: 'task-1', + entityVersionId: 'ver-1', + projectId: 'project-1', + versionId: 'ver-1', + content: '请 @Alice 看一下接口,Bob 也同步一下', + mentionMemberIds: ['m-bob'], + }); + + expect(result.id).toBe('comment-1'); + expect(prisma.comment.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entityType: 'dev_task', + entityId: 'task-1', + authorId: 'm-author', + mentionedMemberIds: ['m-alice', 'm-bob'], + }), + }); + expect((notifications.createMany as jest.Mock)).toHaveBeenCalledWith([ + expect.objectContaining({ recipientId: 'm-alice', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }), + expect.objectContaining({ recipientId: 'm-bob', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }), + ]); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + actorId: 'm-author', + action: 'comment.created', + resourceType: 'comment', + resourceId: 'comment-1', + })); + }); + + it('soft deletes a comment and writes audit', async () => { + const { prisma, audit, service } = makeService(); + prisma.comment.findUnique.mockResolvedValue({ + id: 'comment-1', + authorId: 'm-author', + entityType: 'bug', + entityId: 'bug-1', + deletedAt: null, + }); + prisma.comment.update.mockResolvedValue({ id: 'comment-1', deletedAt: new Date('2026-07-08T08:00:00.000Z') }); + + await service.remove('comment-1', 'm-author'); + + expect(prisma.comment.update).toHaveBeenCalledWith({ + where: { id: 'comment-1' }, + data: { deletedAt: expect.any(Date) }, + }); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + actorId: 'm-author', + action: 'comment.deleted', + resourceType: 'comment', + resourceId: 'comment-1', + })); + }); + + it('rejects unsupported comment entity types', async () => { + const { service } = makeService(); + + await expect(service.create({ + actorId: 'm-author', + entityType: 'task', + entityId: 'task-1', + content: 'legacy task comment', + } as any)).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/server/src/modules/comment/comment.service.ts b/apps/server/src/modules/comment/comment.service.ts new file mode 100644 index 0000000..1063f90 --- /dev/null +++ b/apps/server/src/modules/comment/comment.service.ts @@ -0,0 +1,157 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { NotificationService } from '../notification/notification.service'; + +export const COMMENT_ENTITY_TYPES = ['dev_task', 'test_case', 'bug', 'requirement', 'version_plan'] as const; +export type CommentEntityType = (typeof COMMENT_ENTITY_TYPES)[number]; + +export interface CommentCreateInput { + actorId: string; + entityType: CommentEntityType; + entityId: string; + entityVersionId?: string | null; + productId?: string | null; + projectId?: string | null; + versionId?: string | null; + content: string; + mentionMemberIds?: string[]; +} + +@Injectable() +export class CommentService { + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + private readonly auditService: AuditService, + ) {} + + list(entityType: CommentEntityType, entityId: string) { + assertCommentEntityType(entityType); + return this.prisma.comment.findMany({ + where: { entityType, entityId: requireText(entityId, 'entityId'), deletedAt: null }, + orderBy: { createdAt: 'asc' }, + }); + } + + async create(input: CommentCreateInput) { + assertCommentEntityType(input.entityType); + const actorId = requireText(input.actorId, 'actorId'); + const content = requireText(input.content, 'content'); + const mentionedMemberIds = await this.resolveMentionMemberIds(content, input.projectId, input.mentionMemberIds); + const comment = await this.prisma.comment.create({ + data: { + entityType: input.entityType, + entityId: requireText(input.entityId, 'entityId'), + entityVersionId: input.entityVersionId ?? null, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + versionId: input.versionId ?? null, + authorId: actorId, + content, + mentionedMemberIds, + }, + }); + + await this.notificationService.createMany(mentionedMemberIds + .filter((recipientId) => recipientId !== actorId) + .map((recipientId) => ({ + recipientId, + actorId, + type: 'mention', + title: '你被提及了', + body: content, + resourceType: 'comment', + resourceId: comment.id, + resourceVersionId: input.entityVersionId ?? null, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + versionId: input.versionId ?? null, + metadata: { + entityType: input.entityType, + entityId: input.entityId, + }, + }))); + + await this.auditService.record({ + actorId, + action: 'comment.created', + resourceType: 'comment', + resourceId: comment.id, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + versionId: input.versionId ?? null, + after: comment, + }); + + return comment; + } + + async remove(id: string, actorId: string) { + const comment = await this.prisma.comment.findUnique({ where: { id: requireText(id, 'id') } }); + if (!comment || comment.deletedAt) throw new NotFoundException('Comment not found'); + + const removed = await this.prisma.comment.update({ + where: { id: comment.id }, + data: { deletedAt: new Date() }, + }); + await this.auditService.record({ + actorId: requireText(actorId, 'actorId'), + action: 'comment.deleted', + resourceType: 'comment', + resourceId: comment.id, + productId: comment.productId, + projectId: comment.projectId, + versionId: comment.versionId, + before: comment, + after: removed, + }); + return removed; + } + + private async resolveMentionMemberIds(content: string, projectId?: string | null, explicitIds: string[] = []): Promise { + const ids = new Set(); + const mentionNames = extractMentionNames(content); + if (mentionNames.length > 0 && projectId?.trim()) { + const projectMembers = await this.prisma.projectMember.findMany({ + where: { projectId: projectId.trim() }, + include: { user: { select: { id: true, name: true } } }, + }); + const wanted = new Set(mentionNames.map(normalizeMentionName)); + for (const member of projectMembers as Array<{ userId: string; user?: { id?: string; name?: string } }>) { + const name = normalizeMentionName(member.user?.name); + if (name && wanted.has(name)) ids.add(member.user?.id ?? member.userId); + } + } + for (const explicitId of explicitIds) { + const id = explicitId.trim(); + if (id) ids.add(id); + } + return Array.from(ids); + } +} + +export function extractMentionNames(content: string): string[] { + const names: string[] = []; + const pattern = /@([\p{L}\p{N}_\-.]+)/gu; + for (const match of content.matchAll(pattern)) { + if (match[1]) names.push(match[1]); + } + return names; +} + +function assertCommentEntityType(entityType: string): asserts entityType is CommentEntityType { + if (!COMMENT_ENTITY_TYPES.includes(entityType as CommentEntityType)) { + throw new BadRequestException(`Unsupported comment entity type: ${entityType}`); + } +} + +function normalizeMentionName(name?: string | null): string { + return name?.trim().toLowerCase() ?? ''; +} + +function requireText(value: string | undefined | null, field: string): string { + const normalized = value?.trim(); + if (!normalized) throw new BadRequestException(`${field} is required`); + return normalized; +} diff --git a/apps/server/src/modules/comment/dto/create-comment.dto.ts b/apps/server/src/modules/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..d023103 --- /dev/null +++ b/apps/server/src/modules/comment/dto/create-comment.dto.ts @@ -0,0 +1,37 @@ +import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; +import { COMMENT_ENTITY_TYPES, type CommentEntityType } from '../comment.service'; + +export class CreateCommentDto { + @IsString() + actorId!: string; + + @IsIn(COMMENT_ENTITY_TYPES) + entityType!: CommentEntityType; + + @IsString() + entityId!: string; + + @IsOptional() + @IsString() + entityVersionId?: string; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @IsString() + projectId?: string; + + @IsOptional() + @IsString() + versionId?: string; + + @IsString() + content!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + mentionMemberIds?: string[]; +} diff --git a/apps/server/src/modules/comment/dto/delete-comment.dto.ts b/apps/server/src/modules/comment/dto/delete-comment.dto.ts new file mode 100644 index 0000000..9bac9dc --- /dev/null +++ b/apps/server/src/modules/comment/dto/delete-comment.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteCommentDto { + @IsString() + actorId!: string; +} diff --git a/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts b/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts new file mode 100644 index 0000000..5eb9eba --- /dev/null +++ b/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts @@ -0,0 +1,56 @@ +import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { GOVERNANCE_DICTIONARY_KINDS, type GovernanceDictionaryKind } from '../governance.service'; + +export class GovernanceDictionaryDto { + @IsString() + actorId!: string; + + @IsIn(GOVERNANCE_DICTIONARY_KINDS) + kind!: GovernanceDictionaryKind; + + @IsString() + name!: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + group?: string; + + @IsOptional() + @IsString() + scope?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; +} + +export class RemoveGovernanceDictionaryDto { + @IsString() + actorId!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; +} + +export class ImportGovernanceDictionariesDto { + @IsString() + actorId!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => GovernanceDictionaryDto) + items!: GovernanceDictionaryDto[]; +} diff --git a/apps/server/src/modules/governance/governance.controller.ts b/apps/server/src/modules/governance/governance.controller.ts new file mode 100644 index 0000000..13c65a2 --- /dev/null +++ b/apps/server/src/modules/governance/governance.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { GovernanceDictionaryDto, ImportGovernanceDictionariesDto, RemoveGovernanceDictionaryDto } from './dto/governance-dictionary.dto'; +import { GovernanceDictionaryKind, GovernanceService } from './governance.service'; + +@Controller('governance') +export class GovernanceController { + constructor(private readonly governanceService: GovernanceService) {} + + @Get('dictionaries') + list(@Query('kind') kind: GovernanceDictionaryKind) { + return this.governanceService.list(kind); + } + + @Post('dictionaries') + create(@Body() dto: GovernanceDictionaryDto) { + return this.governanceService.create(dto); + } + + @Patch('dictionaries/:kind/:id') + update( + @Param('kind') kind: GovernanceDictionaryKind, + @Param('id') id: string, + @Body() dto: GovernanceDictionaryDto, + ) { + return this.governanceService.update({ ...dto, kind, id }); + } + + @Delete('dictionaries/:kind/:id') + remove( + @Param('kind') kind: GovernanceDictionaryKind, + @Param('id') id: string, + @Body() dto: RemoveGovernanceDictionaryDto, + ) { + return this.governanceService.remove({ actorId: dto.actorId, permissions: dto.permissions, kind, id }); + } + + @Get('export') + exportAll() { + return this.governanceService.exportAll(); + } + + @Post('import') + importAll(@Body() dto: ImportGovernanceDictionariesDto) { + return this.governanceService.importAll(dto.actorId, dto.items, dto.permissions); + } +} diff --git a/apps/server/src/modules/governance/governance.module.ts b/apps/server/src/modules/governance/governance.module.ts new file mode 100644 index 0000000..1143d42 --- /dev/null +++ b/apps/server/src/modules/governance/governance.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CommonDomainModule } from '../../common/common-domain.module'; +import { GovernanceController } from './governance.controller'; +import { GovernanceService } from './governance.service'; + +@Module({ + imports: [CommonDomainModule], + controllers: [GovernanceController], + providers: [GovernanceService], + exports: [GovernanceService], +}) +export class GovernanceModule {} diff --git a/apps/server/src/modules/governance/governance.service.spec.ts b/apps/server/src/modules/governance/governance.service.spec.ts new file mode 100644 index 0000000..2f44aa2 --- /dev/null +++ b/apps/server/src/modules/governance/governance.service.spec.ts @@ -0,0 +1,93 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { RbacService } from '../../common/rbac/rbac.service'; +import { GovernanceService } from './governance.service'; + +describe('GovernanceService', () => { + const makeService = () => { + const prisma = { + taskCategory: { + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + devTask: { count: jest.fn() }, + testCase: { count: jest.fn() }, + governanceDictionary: { + create: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + upsert: jest.fn(), + }, + }; + const audit = { record: jest.fn() } as unknown as AuditService; + const rbac = { + assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-admin', via: 'system' }), + } as unknown as RbacService; + return { prisma, audit, rbac, service: new GovernanceService(prisma as any, audit, rbac) }; + }; + + it('requires governance manage permission through the RBAC adapter', async () => { + const { prisma, rbac, service } = makeService(); + (rbac.assertGlobalPermission as jest.Mock).mockRejectedValue(new Error('forbidden')); + + await expect(service.create({ + actorId: 'm-dev', + permissions: ['project:view'], + kind: 'requirement_type', + name: '新功能', + })).rejects.toThrow('forbidden'); + + expect(prisma.governanceDictionary.create).not.toHaveBeenCalled(); + }); + + it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => { + const { prisma, rbac, service } = makeService(); + prisma.devTask.count.mockResolvedValue(1); + prisma.testCase.count.mockResolvedValue(0); + + await expect(service.remove({ + actorId: 'm-admin', + permissions: ['governance:manage'], + kind: 'task_category', + id: 'cat-1', + })).rejects.toBeInstanceOf(BadRequestException); + + expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({ + actorId: 'm-admin', + permissions: ['governance:manage'], + requiredPermissions: ['governance:manage'], + }); + expect(prisma.taskCategory.delete).not.toHaveBeenCalled(); + }); + + it('soft deletes requirement dictionaries and writes audit', async () => { + const { prisma, audit, service } = makeService(); + prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') }); + + await service.remove({ actorId: 'm-admin', permissions: ['governance:manage'], kind: 'requirement_type', id: 'dict-1' }); + + expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({ + where: { id: 'dict-1' }, + data: { deletedAt: expect.any(Date) }, + }); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + actorId: 'm-admin', + action: 'governance.dictionary_deleted', + resourceType: 'governance_dictionary', + resourceId: 'dict-1', + })); + }); + + it('exports task categories and governance dictionaries together', async () => { + const { prisma, service } = makeService(); + prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]); + prisma.governanceDictionary.findMany.mockResolvedValue([{ id: 'type-1', kind: 'requirement_type', name: '新功能' }]); + + await expect(service.exportAll()).resolves.toEqual({ + taskCategories: [{ id: 'cat-1', name: '前端' }], + dictionaries: [{ id: 'type-1', kind: 'requirement_type', name: '新功能' }], + }); + }); +}); diff --git a/apps/server/src/modules/governance/governance.service.ts b/apps/server/src/modules/governance/governance.service.ts new file mode 100644 index 0000000..0512e4c --- /dev/null +++ b/apps/server/src/modules/governance/governance.service.ts @@ -0,0 +1,227 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { RbacService } from '../../common/rbac/rbac.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const; +export type GovernanceDictionaryKind = (typeof GOVERNANCE_DICTIONARY_KINDS)[number]; + +export interface GovernanceDictionaryInput { + actorId: string; + kind: GovernanceDictionaryKind; + id?: string; + name: string; + code?: string | null; + group?: string | null; + scope?: string; + value?: unknown; + permissions?: string[]; +} + +export interface GovernanceRemoveInput { + actorId: string; + kind: GovernanceDictionaryKind; + id: string; + permissions?: string[]; +} + +@Injectable() +export class GovernanceService { + constructor( + private readonly prisma: PrismaService, + private readonly auditService: AuditService, + private readonly rbacService: RbacService, + ) {} + + list(kind: GovernanceDictionaryKind) { + assertDictionaryKind(kind); + if (kind === 'task_category') { + return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }); + } + return this.prisma.governanceDictionary.findMany({ + where: { kind, deletedAt: null }, + orderBy: { name: 'asc' }, + }); + } + + async create(input: GovernanceDictionaryInput) { + assertDictionaryKind(input.kind); + const actorId = requireText(input.actorId, 'actorId'); + await this.assertManagePermission(actorId, input.permissions); + const created = input.kind === 'task_category' + ? await this.prisma.taskCategory.create({ + data: { + name: requireText(input.name, 'name'), + code: input.code ?? null, + group: input.group ?? 'other', + }, + }) + : await this.prisma.governanceDictionary.create({ + data: { + scope: input.scope ?? 'global', + kind: input.kind, + name: requireText(input.name, 'name'), + code: input.code ?? null, + group: input.group ?? null, + value: input.value ?? {}, + }, + }); + + await this.auditService.record({ + actorId, + action: 'governance.dictionary_created', + resourceType: this.resourceType(input.kind), + resourceId: created.id, + after: created, + }); + return created; + } + + async update(input: GovernanceDictionaryInput & { id: string }) { + assertDictionaryKind(input.kind); + const actorId = requireText(input.actorId, 'actorId'); + await this.assertManagePermission(actorId, input.permissions); + const id = requireText(input.id, 'id'); + const updated = input.kind === 'task_category' + ? await this.prisma.taskCategory.update({ + where: { id }, + data: { + name: requireText(input.name, 'name'), + code: input.code ?? null, + group: input.group ?? 'other', + }, + }) + : await this.prisma.governanceDictionary.update({ + where: { id }, + data: { + name: requireText(input.name, 'name'), + code: input.code ?? null, + group: input.group ?? null, + value: input.value ?? {}, + }, + }); + + await this.auditService.record({ + actorId, + action: 'governance.dictionary_updated', + resourceType: this.resourceType(input.kind), + resourceId: id, + after: updated, + }); + return updated; + } + + async remove(input: GovernanceRemoveInput) { + assertDictionaryKind(input.kind); + const actorId = requireText(input.actorId, 'actorId'); + await this.assertManagePermission(actorId, input.permissions); + const id = requireText(input.id, 'id'); + if (input.kind === 'task_category') { + const [devTaskCount, testCaseCount] = await Promise.all([ + this.prisma.devTask.count({ where: { categoryId: id } }), + this.prisma.testCase.count({ where: { categoryId: id } }), + ]); + if (devTaskCount + testCaseCount > 0) { + throw new BadRequestException('Dictionary item is in use and cannot be hard deleted'); + } + const removed = await this.prisma.taskCategory.delete({ where: { id } }); + await this.auditService.record({ + actorId, + action: 'governance.dictionary_deleted', + resourceType: 'task_category', + resourceId: id, + before: removed, + }); + return removed; + } + + const removed = await this.prisma.governanceDictionary.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + await this.auditService.record({ + actorId, + action: 'governance.dictionary_deleted', + resourceType: 'governance_dictionary', + resourceId: id, + after: removed, + }); + return removed; + } + + async exportAll() { + const [taskCategories, dictionaries] = await Promise.all([ + this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }), + this.prisma.governanceDictionary.findMany({ where: { deletedAt: null }, orderBy: [{ kind: 'asc' }, { name: 'asc' }] }), + ]); + return { taskCategories, dictionaries }; + } + + async importAll(actorId: string, items: GovernanceDictionaryInput[], permissions: string[] = []) { + await this.assertManagePermission(actorId, permissions); + const results = []; + for (const item of items) { + assertDictionaryKind(item.kind); + if (item.kind === 'task_category') { + results.push(await this.create({ ...item, actorId, permissions })); + } else { + const saved = await this.prisma.governanceDictionary.upsert({ + where: { + scope_kind_name: { + scope: item.scope ?? 'global', + kind: item.kind, + name: requireText(item.name, 'name'), + }, + }, + update: { + code: item.code ?? null, + group: item.group ?? null, + value: item.value ?? {}, + deletedAt: null, + }, + create: { + scope: item.scope ?? 'global', + kind: item.kind, + name: requireText(item.name, 'name'), + code: item.code ?? null, + group: item.group ?? null, + value: item.value ?? {}, + }, + }); + results.push(saved); + } + } + await this.auditService.record({ + actorId: requireText(actorId, 'actorId'), + action: 'governance.dictionary_imported', + resourceType: 'governance_dictionary', + resourceId: 'bulk', + after: { count: results.length }, + }); + return { count: results.length, items: results }; + } + + private resourceType(kind: GovernanceDictionaryKind) { + return kind === 'task_category' ? 'task_category' : 'governance_dictionary'; + } + + private assertManagePermission(actorId: string, permissions: string[] = []) { + return this.rbacService.assertGlobalPermission({ + actorId, + permissions, + requiredPermissions: ['governance:manage'], + }); + } +} + +function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind { + if (!GOVERNANCE_DICTIONARY_KINDS.includes(kind as GovernanceDictionaryKind)) { + throw new BadRequestException(`Unsupported governance dictionary kind: ${kind}`); + } +} + +function requireText(value: string | undefined | null, field: string): string { + const normalized = value?.trim(); + if (!normalized) throw new BadRequestException(`${field} is required`); + return normalized; +} diff --git a/apps/server/src/modules/management/management.controller.ts b/apps/server/src/modules/management/management.controller.ts new file mode 100644 index 0000000..45ba5ee --- /dev/null +++ b/apps/server/src/modules/management/management.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ManagementService } from './management.service'; + +@Controller('management') +export class ManagementController { + constructor(private readonly managementService: ManagementService) {} + + @Get('overview') + getOverview( + @Query('actorId') actorId: string, + @Query('permissions') permissions?: string, + ) { + return this.managementService.getOverview({ + actorId, + permissions: permissions ? permissions.split(',').map((item) => item.trim()).filter(Boolean) : [], + }); + } +} diff --git a/apps/server/src/modules/management/management.module.ts b/apps/server/src/modules/management/management.module.ts new file mode 100644 index 0000000..b79a32e --- /dev/null +++ b/apps/server/src/modules/management/management.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { CommonDomainModule } from '../../common/common-domain.module'; +import { ManagementController } from './management.controller'; +import { ManagementService } from './management.service'; + +@Module({ + imports: [CommonDomainModule], + controllers: [ManagementController], + providers: [ManagementService], +}) +export class ManagementModule {} diff --git a/apps/server/src/modules/management/management.service.spec.ts b/apps/server/src/modules/management/management.service.spec.ts new file mode 100644 index 0000000..df1d537 --- /dev/null +++ b/apps/server/src/modules/management/management.service.spec.ts @@ -0,0 +1,90 @@ +import { ManagementService } from './management.service'; + +describe('ManagementService', () => { + const makeService = () => { + const prisma = { + appData: { findMany: jest.fn() }, + projectMember: { findMany: jest.fn() }, + version: { findMany: jest.fn() }, + versionPlan: { findMany: jest.fn() }, + devTask: { findMany: jest.fn() }, + testCase: { findMany: jest.fn() }, + bug: { findMany: jest.fn() }, + xiaobaoRiskSummary: { findMany: jest.fn() }, + }; + const rbac = { + assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-manager', via: 'permission' }), + }; + return { prisma, rbac, service: new ManagementService(prisma as any, rbac as any) }; + }; + + it('requires management view permission through the RBAC adapter', async () => { + const { rbac, service } = makeService(); + rbac.assertGlobalPermission.mockRejectedValue(new Error('forbidden')); + + await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).rejects.toThrow('forbidden'); + }); + + it('returns an empty dashboard when actor has no managed projects', async () => { + const { prisma, rbac, service } = makeService(); + prisma.projectMember.findMany.mockResolvedValue([]); + + await expect(service.getOverview({ actorId: 'm-dev', permissions: ['management:view'] })).resolves.toEqual({ + activeVersionCount: 0, + overdueItemCount: 0, + blockedItemCount: 0, + riskCounts: {}, + memberLoads: [], + activeVersions: [], + overdueItems: [], + blockedItems: [], + highRiskVersions: [], + }); + expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({ + actorId: 'm-dev', + permissions: ['management:view'], + requiredPermissions: ['management:view'], + }); + expect(prisma.appData.findMany).not.toHaveBeenCalled(); + }); + + it('aggregates active versions, overdue work, blockers, risks, and member load from relation tables only', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }]); + prisma.version.findMany.mockResolvedValue([ + { id: 'ver-1', projectId: 'project-1', name: 'V1', releaseDate: new Date('2026-07-20T00:00:00.000Z') }, + ]); + prisma.versionPlan.findMany.mockResolvedValue([ + { id: 'plan-1', versionId: 'ver-1', title: '产品方案', ownerId: 'm-pm', status: 'in_progress', expectedEndAt: new Date('2026-07-01T00:00:00.000Z') }, + ]); + prisma.devTask.findMany.mockResolvedValue([ + { id: 'task-1', versionId: 'ver-1', title: '接口开发', assigneeId: 'm-dev', status: 'in_progress', isBlocked: true, expectedEndAt: new Date('2026-07-01T00:00:00.000Z') }, + ]); + prisma.testCase.findMany.mockResolvedValue([ + { id: 'tc-1', versionId: 'ver-1', title: '权限测试', assigneeId: 'm-qa', status: 'running', plannedEndAt: new Date('2026-07-01T00:00:00.000Z') }, + ]); + prisma.bug.findMany.mockResolvedValue([ + { id: 'bug-1', versionId: 'ver-1', title: '线上缺陷', assigneeId: 'm-dev', status: 'open', plannedFixAt: new Date('2026-07-01T00:00:00.000Z') }, + ]); + prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([ + { versionId: 'ver-1', riskLevel: 'likely_delayed', riskScore: 82 }, + ]); + + const result = await service.getOverview({ + actorId: 'm-manager', + permissions: [], + now: new Date('2026-07-08T00:00:00.000Z'), + }); + + expect(result.activeVersionCount).toBe(1); + expect(result.overdueItemCount).toBe(4); + expect(result.blockedItemCount).toBe(1); + expect(result.riskCounts).toEqual({ likely_delayed: 1 }); + expect(result.memberLoads).toEqual([ + { memberId: 'm-dev', openItemCount: 2 }, + { memberId: 'm-pm', openItemCount: 1 }, + { memberId: 'm-qa', openItemCount: 1 }, + ]); + expect(prisma.appData.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/management/management.service.ts b/apps/server/src/modules/management/management.service.ts new file mode 100644 index 0000000..2756099 --- /dev/null +++ b/apps/server/src/modules/management/management.service.ts @@ -0,0 +1,162 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { RbacService } from '../../common/rbac/rbac.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface ManagementOverviewQuery { + actorId?: string; + permissions?: string[]; + now?: Date; +} + +type WorkItem = { + id: string; + versionId: string; + title: string; + ownerId?: string | null; + assigneeId?: string | null; + status?: string; + isBlocked?: boolean; + expectedEndAt?: Date | string | null; + plannedEndAt?: Date | string | null; + plannedFixAt?: Date | string | null; +}; + +@Injectable() +export class ManagementService { + constructor( + private readonly prisma: PrismaService, + private readonly rbacService: RbacService, + ) {} + + async getOverview(query: ManagementOverviewQuery) { + const actorId = query.actorId?.trim(); + if (!actorId) throw new BadRequestException('actorId is required'); + const permission = await this.rbacService.assertGlobalPermission({ + actorId, + permissions: query.permissions ?? [], + requiredPermissions: ['management:view'], + }); + const now = query.now ?? new Date(); + const allProjects = permission.via === 'system'; + const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId); + if (!allProjects && projectIds?.length === 0) return emptyOverview(); + + const activeVersions = await this.prisma.version.findMany({ + where: { + ...(projectIds ? { projectId: { in: projectIds } } : {}), + OR: [{ releaseDate: null }, { releaseDate: { gte: now } }], + }, + orderBy: { releaseDate: 'asc' }, + }); + const versionIds = activeVersions.map((version: { id: string }) => version.id); + if (versionIds.length === 0) { + return { ...emptyOverview(), activeVersions }; + } + + const [versionPlans, devTasks, testCases, bugs, highRiskVersions] = await Promise.all([ + this.prisma.versionPlan.findMany({ + where: { versionId: { in: versionIds }, status: { not: 'completed' } }, + }), + this.prisma.devTask.findMany({ + where: { versionId: { in: versionIds }, status: { not: 'submitted' } }, + }), + this.prisma.testCase.findMany({ + where: { versionId: { in: versionIds }, status: { notIn: ['passed', 'failed', 'blocked'] } }, + }), + this.prisma.bug.findMany({ + where: { versionId: { in: versionIds }, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } }, + }), + this.prisma.xiaobaoRiskSummary.findMany({ + where: { versionId: { in: versionIds }, riskLevel: { not: 'on_track' } }, + orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }], + }), + ]); + + const overdueItems = [ + ...collectOverdue(versionPlans, 'version_plan', now, 'expectedEndAt'), + ...collectOverdue(devTasks, 'dev_task', now, 'expectedEndAt'), + ...collectOverdue(testCases, 'test_case', now, 'plannedEndAt'), + ...collectOverdue(bugs, 'bug', now, 'plannedFixAt'), + ]; + const blockedItems = [ + ...devTasks.filter((item: WorkItem) => item.isBlocked).map((item: WorkItem) => toDashboardItem(item, 'dev_task')), + ...testCases.filter((item: WorkItem) => item.status === 'blocked').map((item: WorkItem) => toDashboardItem(item, 'test_case')), + ]; + + return { + activeVersionCount: activeVersions.length, + overdueItemCount: overdueItems.length, + blockedItemCount: blockedItems.length, + riskCounts: countByRiskLevel(highRiskVersions), + memberLoads: buildMemberLoads(versionPlans, devTasks, testCases, bugs), + activeVersions, + overdueItems, + blockedItems, + highRiskVersions, + }; + } + + private async getManagedProjectIds(actorId: string): Promise { + const rows = await this.prisma.projectMember.findMany({ + where: { userId: actorId, role: { in: ['owner', 'admin'] } }, + select: { projectId: true }, + }); + return Array.from(new Set(rows.map((row: { projectId: string }) => row.projectId))); + } +} + +function emptyOverview() { + return { + activeVersionCount: 0, + overdueItemCount: 0, + blockedItemCount: 0, + riskCounts: {}, + memberLoads: [], + activeVersions: [], + overdueItems: [], + blockedItems: [], + highRiskVersions: [], + }; +} + +function collectOverdue(items: WorkItem[], type: string, now: Date, dateField: keyof WorkItem) { + return items + .filter((item) => isBefore(item[dateField], now)) + .map((item) => ({ ...toDashboardItem(item, type), dueAt: item[dateField] })); +} + +function toDashboardItem(item: WorkItem, type: string) { + return { + type, + id: item.id, + versionId: item.versionId, + title: item.title, + ownerId: item.ownerId ?? item.assigneeId ?? null, + status: item.status, + }; +} + +function isBefore(value: unknown, now: Date): boolean { + if (!value) return false; + const time = value instanceof Date ? value.getTime() : new Date(String(value)).getTime(); + return Number.isFinite(time) && time < now.getTime(); +} + +function countByRiskLevel(rows: Array<{ riskLevel: string }>): Record { + return rows.reduce>((acc, row) => { + acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1; + return acc; + }, {}); +} + +function buildMemberLoads(...groups: WorkItem[][]) { + const counts = new Map(); + for (const item of groups.flat()) { + const memberId = item.ownerId ?? item.assigneeId; + if (!memberId) continue; + counts.set(memberId, (counts.get(memberId) ?? 0) + 1); + } + return Array.from(counts.entries()) + .map(([memberId, openItemCount]) => ({ memberId, openItemCount })) + .sort((a, b) => b.openItemCount - a.openItemCount || a.memberId.localeCompare(b.memberId)); +} diff --git a/apps/server/src/modules/notification/dto/create-notification.dto.ts b/apps/server/src/modules/notification/dto/create-notification.dto.ts new file mode 100644 index 0000000..01b4b55 --- /dev/null +++ b/apps/server/src/modules/notification/dto/create-notification.dto.ts @@ -0,0 +1,47 @@ +import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; +import { NOTIFICATION_TYPES, type NotificationType } from '../notification.service'; + +export class CreateNotificationDto { + @IsString() + recipientId!: string; + + @IsOptional() + @IsString() + actorId?: string; + + @IsIn(NOTIFICATION_TYPES) + type!: NotificationType; + + @IsString() + title!: string; + + @IsOptional() + @IsString() + body?: string; + + @IsString() + resourceType!: string; + + @IsString() + resourceId!: string; + + @IsOptional() + @IsString() + resourceVersionId?: string; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @IsString() + projectId?: string; + + @IsOptional() + @IsString() + versionId?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/apps/server/src/modules/notification/dto/mark-notification-read.dto.ts b/apps/server/src/modules/notification/dto/mark-notification-read.dto.ts new file mode 100644 index 0000000..ae2c2cf --- /dev/null +++ b/apps/server/src/modules/notification/dto/mark-notification-read.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class MarkNotificationReadDto { + @IsString() + recipientId!: string; +} diff --git a/apps/server/src/modules/notification/notification.controller.ts b/apps/server/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..beea318 --- /dev/null +++ b/apps/server/src/modules/notification/notification.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { CreateNotificationDto } from './dto/create-notification.dto'; +import { MarkNotificationReadDto } from './dto/mark-notification-read.dto'; +import { NotificationService } from './notification.service'; + +@Controller('notifications') +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Get() + list( + @Query('recipientId') recipientId: string, + @Query('unreadOnly') unreadOnly?: string, + @Query('limit') limit?: string, + ) { + return this.notificationService.list({ + recipientId, + unreadOnly: unreadOnly === 'true', + limit: limit ? Number(limit) : undefined, + }); + } + + @Post() + create(@Body() dto: CreateNotificationDto) { + return this.notificationService.create(dto); + } + + @Patch(':id/read') + markRead(@Param('id') id: string, @Body() dto: MarkNotificationReadDto) { + return this.notificationService.markRead(id, dto.recipientId); + } + + @Patch('read-all') + markAllRead(@Body() dto: MarkNotificationReadDto) { + return this.notificationService.markAllRead(dto.recipientId); + } +} diff --git a/apps/server/src/modules/notification/notification.module.ts b/apps/server/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..d94cc81 --- /dev/null +++ b/apps/server/src/modules/notification/notification.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NotificationController } from './notification.controller'; +import { NotificationService } from './notification.service'; + +@Module({ + controllers: [NotificationController], + providers: [NotificationService], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/apps/server/src/modules/notification/notification.service.spec.ts b/apps/server/src/modules/notification/notification.service.spec.ts new file mode 100644 index 0000000..5778472 --- /dev/null +++ b/apps/server/src/modules/notification/notification.service.spec.ts @@ -0,0 +1,79 @@ +import { NotificationService } from './notification.service'; + +describe('NotificationService', () => { + const makeService = () => { + const prisma = { + notification: { + create: jest.fn(), + createMany: jest.fn(), + findMany: jest.fn(), + updateMany: jest.fn(), + }, + }; + return { prisma, service: new NotificationService(prisma as any) }; + }; + + it('lists unread notifications for one recipient in newest-first order', async () => { + const { prisma, service } = makeService(); + prisma.notification.findMany.mockResolvedValue([{ id: 'n-1' }]); + + await expect(service.list({ recipientId: 'm-1', unreadOnly: true, limit: 20 })).resolves.toEqual([{ id: 'n-1' }]); + + expect(prisma.notification.findMany).toHaveBeenCalledWith({ + where: { recipientId: 'm-1', readAt: null }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + }); + + it('marks one notification as read only for the requesting recipient', async () => { + const { prisma, service } = makeService(); + prisma.notification.updateMany.mockResolvedValue({ count: 1 }); + + await service.markRead('n-1', 'm-1'); + + expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + where: { id: 'n-1', recipientId: 'm-1', readAt: null }, + data: { readAt: expect.any(Date) }, + }); + }); + + it('marks all unread notifications for one recipient as read', async () => { + const { prisma, service } = makeService(); + prisma.notification.updateMany.mockResolvedValue({ count: 2 }); + + await service.markAllRead('m-1'); + + expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + where: { recipientId: 'm-1', readAt: null }, + data: { readAt: expect.any(Date) }, + }); + }); + + it('creates notifications with stable V2.7 event types', async () => { + const { prisma, service } = makeService(); + prisma.notification.create.mockResolvedValue({ id: 'n-risk' }); + + await service.create({ + recipientId: 'm-manager', + actorId: 'xiaobao', + type: 'risk_alert', + title: '版本存在延期风险', + resourceType: 'version', + resourceId: 'ver-1', + projectId: 'project-1', + versionId: 'ver-1', + metadata: { riskLevel: 'likely_delayed' }, + }); + + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + recipientId: 'm-manager', + type: 'risk_alert', + resourceType: 'version', + resourceId: 'ver-1', + metadata: { riskLevel: 'likely_delayed' }, + }), + }); + }); +}); diff --git a/apps/server/src/modules/notification/notification.service.ts b/apps/server/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..0f2c5b6 --- /dev/null +++ b/apps/server/src/modules/notification/notification.service.ts @@ -0,0 +1,99 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export const NOTIFICATION_TYPES = ['assignment', 'mention', 'risk_alert', 'overdue_item'] as const; +export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; + +export interface NotificationListQuery { + recipientId: string; + unreadOnly?: boolean; + limit?: number; +} + +export interface NotificationCreateInput { + recipientId: string; + actorId?: string | null; + type: NotificationType; + title: string; + body?: string; + resourceType: string; + resourceId: string; + resourceVersionId?: string | null; + productId?: string | null; + projectId?: string | null; + versionId?: string | null; + metadata?: unknown; +} + +@Injectable() +export class NotificationService { + constructor(private readonly prisma: PrismaService) {} + + list(query: NotificationListQuery) { + const recipientId = requireText(query.recipientId, 'recipientId'); + return this.prisma.notification.findMany({ + where: { + recipientId, + ...(query.unreadOnly ? { readAt: null } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: normalizeLimit(query.limit), + }); + } + + create(input: NotificationCreateInput) { + const data = normalizeNotificationInput(input); + return this.prisma.notification.create({ data }); + } + + async createMany(items: NotificationCreateInput[]) { + const data = items.map(normalizeNotificationInput); + if (data.length === 0) return { count: 0 }; + return this.prisma.notification.createMany({ data }); + } + + markRead(id: string, recipientId: string) { + return this.prisma.notification.updateMany({ + where: { id: requireText(id, 'id'), recipientId: requireText(recipientId, 'recipientId'), readAt: null }, + data: { readAt: new Date() }, + }); + } + + markAllRead(recipientId: string) { + return this.prisma.notification.updateMany({ + where: { recipientId: requireText(recipientId, 'recipientId'), readAt: null }, + data: { readAt: new Date() }, + }); + } +} + +function normalizeNotificationInput(input: NotificationCreateInput) { + if (!NOTIFICATION_TYPES.includes(input.type)) { + throw new BadRequestException(`Unsupported notification type: ${input.type}`); + } + return { + recipientId: requireText(input.recipientId, 'recipientId'), + actorId: input.actorId ?? null, + type: input.type, + title: requireText(input.title, 'title'), + body: input.body ?? '', + resourceType: requireText(input.resourceType, 'resourceType'), + resourceId: requireText(input.resourceId, 'resourceId'), + resourceVersionId: input.resourceVersionId ?? null, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + versionId: input.versionId ?? null, + metadata: input.metadata ?? {}, + }; +} + +function normalizeLimit(limit?: number): number { + if (!Number.isFinite(limit)) return 50; + return Math.max(1, Math.min(100, Math.floor(limit as number))); +} + +function requireText(value: string | undefined | null, field: string): string { + const normalized = value?.trim(); + if (!normalized) throw new BadRequestException(`${field} is required`); + return normalized; +} diff --git a/apps/server/src/modules/project-member/dto/project-member.dto.ts b/apps/server/src/modules/project-member/dto/project-member.dto.ts new file mode 100644 index 0000000..ed11650 --- /dev/null +++ b/apps/server/src/modules/project-member/dto/project-member.dto.ts @@ -0,0 +1,29 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; +import type { ProjectGovernanceRole } from '../../../common/rbac/rbac.service'; + +const PROJECT_ROLES = ['owner', 'admin', 'member', 'viewer'] as const; + +export class AddProjectMemberDto { + @IsString() + actorId!: string; + + @IsString() + userId!: string; + + @IsOptional() + @IsIn(PROJECT_ROLES) + role?: ProjectGovernanceRole; +} + +export class UpdateProjectMemberRoleDto { + @IsString() + actorId!: string; + + @IsIn(PROJECT_ROLES) + role!: ProjectGovernanceRole; +} + +export class RemoveProjectMemberDto { + @IsString() + actorId!: string; +} diff --git a/apps/server/src/modules/project-member/project-member.controller.ts b/apps/server/src/modules/project-member/project-member.controller.ts new file mode 100644 index 0000000..b53a315 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { AddProjectMemberDto, RemoveProjectMemberDto, UpdateProjectMemberRoleDto } from './dto/project-member.dto'; +import { ProjectMemberService } from './project-member.service'; + +@Controller('projects/:projectId/members') +export class ProjectMemberController { + constructor(private readonly projectMemberService: ProjectMemberService) {} + + @Get() + list(@Param('projectId') projectId: string) { + return this.projectMemberService.list(projectId); + } + + @Post() + add(@Param('projectId') projectId: string, @Body() dto: AddProjectMemberDto) { + return this.projectMemberService.add({ ...dto, projectId }); + } + + @Patch(':userId/role') + updateRole( + @Param('projectId') projectId: string, + @Param('userId') userId: string, + @Body() dto: UpdateProjectMemberRoleDto, + ) { + return this.projectMemberService.updateRole({ ...dto, projectId, userId }); + } + + @Delete(':userId') + remove( + @Param('projectId') projectId: string, + @Param('userId') userId: string, + @Body() dto: RemoveProjectMemberDto, + ) { + return this.projectMemberService.remove({ ...dto, projectId, userId }); + } +} diff --git a/apps/server/src/modules/project-member/project-member.module.ts b/apps/server/src/modules/project-member/project-member.module.ts new file mode 100644 index 0000000..4e58ff2 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CommonDomainModule } from '../../common/common-domain.module'; +import { ProjectMemberController } from './project-member.controller'; +import { ProjectMemberService } from './project-member.service'; + +@Module({ + imports: [CommonDomainModule], + controllers: [ProjectMemberController], + providers: [ProjectMemberService], + exports: [ProjectMemberService], +}) +export class ProjectMemberModule {} diff --git a/apps/server/src/modules/project-member/project-member.service.spec.ts b/apps/server/src/modules/project-member/project-member.service.spec.ts new file mode 100644 index 0000000..4c8a064 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.service.spec.ts @@ -0,0 +1,138 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { RbacService } from '../../common/rbac/rbac.service'; +import { ProjectMemberService } from './project-member.service'; + +describe('ProjectMemberService', () => { + const makeService = () => { + const prisma = { + projectMember: { + count: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + const rbac = { + assertProjectRole: jest.fn().mockResolvedValue({ role: 'owner' }), + } as unknown as RbacService; + const audit = { + record: jest.fn(), + } as unknown as AuditService; + return { + prisma, + rbac, + audit, + service: new ProjectMemberService(prisma as any, rbac, audit), + }; + }; + + it('lists project members with their user profile', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findMany.mockResolvedValue([{ id: 'pm-1', role: 'owner' }]); + + await expect(service.list('project-1')).resolves.toEqual([{ id: 'pm-1', role: 'owner' }]); + + expect(prisma.projectMember.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project-1' }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }); + }); + + it('creates a project member after admin-or-owner authorization and writes audit', async () => { + const { prisma, rbac, audit, service } = makeService(); + prisma.projectMember.create.mockResolvedValue({ id: 'pm-2', role: 'member' }); + + await service.add({ actorId: 'm-owner', projectId: 'project-1', userId: 'm-dev', role: 'member' }); + + expect((rbac.assertProjectRole as jest.Mock)).toHaveBeenCalledWith({ + actorId: 'm-owner', + projectId: 'project-1', + allowedRoles: ['admin'], + }); + expect(prisma.projectMember.create).toHaveBeenCalledWith({ + data: { projectId: 'project-1', userId: 'm-dev', role: 'member' }, + }); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + actorId: 'm-owner', + action: 'project_member.created', + resourceType: 'project_member', + projectId: 'project-1', + })); + }); + + it('rejects demoting the last project owner', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findUnique.mockResolvedValue({ + id: 'pm-owner', + projectId: 'project-1', + userId: 'm-owner', + role: 'owner', + }); + prisma.projectMember.count.mockResolvedValue(1); + + await expect(service.updateRole({ + actorId: 'm-owner', + projectId: 'project-1', + userId: 'm-owner', + role: 'admin', + })).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.projectMember.update).not.toHaveBeenCalled(); + }); + + it('rejects removing the last project owner', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findUnique.mockResolvedValue({ + id: 'pm-owner', + projectId: 'project-1', + userId: 'm-owner', + role: 'owner', + }); + prisma.projectMember.count.mockResolvedValue(1); + + await expect(service.remove({ + actorId: 'm-owner', + projectId: 'project-1', + userId: 'm-owner', + })).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.projectMember.delete).not.toHaveBeenCalled(); + }); + + it('updates roles and writes an audit diff', async () => { + const { prisma, audit, service } = makeService(); + prisma.projectMember.findUnique.mockResolvedValue({ + id: 'pm-dev', + projectId: 'project-1', + userId: 'm-dev', + role: 'member', + }); + prisma.projectMember.update.mockResolvedValue({ + id: 'pm-dev', + projectId: 'project-1', + userId: 'm-dev', + role: 'admin', + }); + + await service.updateRole({ + actorId: 'm-owner', + projectId: 'project-1', + userId: 'm-dev', + role: 'admin', + }); + + expect(prisma.projectMember.update).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId: 'project-1', userId: 'm-dev' } }, + data: { role: 'admin' }, + }); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + action: 'project_member.role_changed', + before: expect.objectContaining({ role: 'member' }), + after: expect.objectContaining({ role: 'admin' }), + })); + }); +}); diff --git a/apps/server/src/modules/project-member/project-member.service.ts b/apps/server/src/modules/project-member/project-member.service.ts new file mode 100644 index 0000000..5c90112 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.service.ts @@ -0,0 +1,129 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { ProjectGovernanceRole, RbacService, normalizeProjectRole } from '../../common/rbac/rbac.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface ProjectMemberMutationInput { + actorId: string; + projectId: string; + userId: string; + role?: ProjectGovernanceRole; +} + +@Injectable() +export class ProjectMemberService { + constructor( + private readonly prisma: PrismaService, + private readonly rbacService: RbacService, + private readonly auditService: AuditService, + ) {} + + list(projectId: string) { + return this.prisma.projectMember.findMany({ + where: { projectId: requireText(projectId, 'projectId') }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }); + } + + async add(input: ProjectMemberMutationInput) { + const projectId = requireText(input.projectId, 'projectId'); + const userId = requireText(input.userId, 'userId'); + const actorId = requireText(input.actorId, 'actorId'); + const role = normalizeRequiredRole(input.role ?? 'member'); + await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] }); + + const created = await this.prisma.projectMember.create({ + data: { projectId, userId, role }, + }); + await this.auditService.record({ + actorId, + action: 'project_member.created', + resourceType: 'project_member', + resourceId: created.id, + projectId, + after: created, + }); + return created; + } + + async updateRole(input: Required) { + const projectId = requireText(input.projectId, 'projectId'); + const userId = requireText(input.userId, 'userId'); + const actorId = requireText(input.actorId, 'actorId'); + const role = normalizeRequiredRole(input.role); + await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] }); + + const existing = await this.findMembership(projectId, userId); + if (existing.role === 'owner' && role !== 'owner') { + await this.assertOwnerWillRemain(projectId); + } + + const updated = await this.prisma.projectMember.update({ + where: { projectId_userId: { projectId, userId } }, + data: { role }, + }); + await this.auditService.record({ + actorId, + action: 'project_member.role_changed', + resourceType: 'project_member', + resourceId: updated.id, + projectId, + before: existing, + after: updated, + }); + return updated; + } + + async remove(input: Omit) { + const projectId = requireText(input.projectId, 'projectId'); + const userId = requireText(input.userId, 'userId'); + const actorId = requireText(input.actorId, 'actorId'); + await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] }); + + const existing = await this.findMembership(projectId, userId); + if (existing.role === 'owner') { + await this.assertOwnerWillRemain(projectId); + } + + const removed = await this.prisma.projectMember.delete({ + where: { projectId_userId: { projectId, userId } }, + }); + await this.auditService.record({ + actorId, + action: 'project_member.deleted', + resourceType: 'project_member', + resourceId: removed.id, + projectId, + before: existing, + }); + return removed; + } + + private async findMembership(projectId: string, userId: string) { + const membership = await this.prisma.projectMember.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }); + if (!membership) throw new NotFoundException('Project member not found'); + return membership; + } + + private async assertOwnerWillRemain(projectId: string) { + const ownerCount = await this.prisma.projectMember.count({ where: { projectId, role: 'owner' } }); + if (ownerCount <= 1) { + throw new BadRequestException('Cannot remove or demote the last project owner'); + } + } +} + +function normalizeRequiredRole(role: string | undefined): ProjectGovernanceRole { + const normalized = normalizeProjectRole(role); + if (!normalized) throw new BadRequestException(`Unsupported project member role: ${role}`); + return normalized; +} + +function requireText(value: string | undefined | null, field: string): string { + const normalized = value?.trim(); + if (!normalized) throw new BadRequestException(`${field} is required`); + return normalized; +} diff --git a/apps/web/app/admin/governance/page.tsx b/apps/web/app/admin/governance/page.tsx new file mode 100644 index 0000000..e368e15 --- /dev/null +++ b/apps/web/app/admin/governance/page.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Download, Plus, RefreshCcw, Trash2, Upload } from 'lucide-react'; +import { RouteGuard } from '@/components/auth/Guard'; +import { api } from '@/lib/api'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; + +type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source'; + +interface GovernanceItem { + id: string; + kind?: string; + name: string; + code?: string | null; + group?: string | null; + isSystem?: boolean; +} + +const KIND_LABEL: Record = { + task_category: '任务类型', + requirement_type: '需求类型', + requirement_platform: '支持端', + requirement_source: '需求来源', +}; + +function GovernancePageInner() { + const user = useAuthStore((s) => s.user); + const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId)); + const [kind, setKind] = useState('task_category'); + const [items, setItems] = useState([]); + const [name, setName] = useState(''); + const [group, setGroup] = useState('other'); + const [exportText, setExportText] = useState(''); + const actorId = user?.id ?? ''; + const permissions = role?.permissions ?? []; + + const reload = async () => { + const rows = await api.get(`/governance/dictionaries?kind=${kind}`); + setItems(rows); + }; + + useEffect(() => { + void reload().catch(() => setItems([])); + }, [kind]); + + const create = async () => { + if (!actorId || !name.trim()) return; + await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group }); + setName(''); + await reload(); + }; + + const remove = async (item: GovernanceItem) => { + if (!actorId) return; + await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId, permissions }); + await reload(); + }; + + const exportAll = async () => { + const data = await api.get('/governance/export'); + setExportText(JSON.stringify(data, null, 2)); + }; + + const importAll = async () => { + if (!actorId || !exportText.trim()) return; + const parsed = JSON.parse(exportText); + const sourceItems = Array.isArray(parsed.items) + ? parsed.items + : [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))]; + await api.post('/governance/import', { actorId, permissions, items: sourceItems }); + await reload(); + }; + + return ( +
+
+
+
+

治理设置

+

统一维护任务类型、需求类型、支持端与来源字典。

+
+ +
+ +
+ {Object.entries(KIND_LABEL).map(([key, label]) => ( + + ))} +
+ +
+
+ setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> + setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> + +
+
+ {items.map((item) => ( +
+ {item.name} + {item.code ?? '-'} + {item.group ?? '-'} + +
+ ))} + {items.length === 0 &&
暂无字典项
} +
+
+ +
+
+ + +
+