merge: 集成V2.7 企业协作与管理治理
# Conflicts: # apps/server/src/app.module.ts # apps/web/components/layout/Sidebar.tsx # apps/web/lib/permissions.ts # docs/architecture.md # docs/decisions.md # docs/roadmap.md
This commit is contained in:
@@ -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: [],
|
||||
|
||||
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('AuditService V2.7 adapter contract', () => {
|
||||
it('writes an append-only audit event with actor and resource scope', async () => {
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||
},
|
||||
};
|
||||
const service = new AuditService(prisma as any);
|
||||
|
||||
await service.record({
|
||||
actorId: 'm-admin',
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
before: { role: 'member' },
|
||||
after: { role: 'admin' },
|
||||
});
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
actorId: 'm-admin',
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
before: { role: 'member' },
|
||||
after: { role: 'admin' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fail the business action when audit storage is unavailable', async () => {
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
create: jest.fn().mockRejectedValue(new Error('database unavailable')),
|
||||
},
|
||||
};
|
||||
const service = new AuditService(prisma as any);
|
||||
(service as any).logger.warn = jest.fn();
|
||||
|
||||
await expect(service.record({
|
||||
actorId: 'm-admin',
|
||||
action: 'comment.created',
|
||||
resourceType: 'comment',
|
||||
resourceId: 'comment-1',
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
43
apps/server/src/common/audit/audit.service.ts
Normal file
43
apps/server/src/common/audit/audit.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(input: AuditEventInput): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
productId: input.productId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
versionId: input.versionId ?? null,
|
||||
before: input.before ?? {},
|
||||
after: input.after ?? {},
|
||||
metadata: input.metadata ?? {},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Audit write failed for ${input.action}: ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/server/src/common/common-domain.module.ts
Normal file
9
apps/server/src/common/common-domain.module.ts
Normal file
@@ -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 {}
|
||||
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
@@ -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<ProjectGovernanceRole, number> = {
|
||||
owner: 4,
|
||||
admin: 3,
|
||||
member: 2,
|
||||
viewer: 1,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RbacService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async assertGlobalPermission(input: GlobalPermissionAssertion): Promise<GlobalPermissionDecision> {
|
||||
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<ProjectRoleDecision> {
|
||||
const actorId = input.actorId?.trim();
|
||||
const projectId = input.projectId?.trim();
|
||||
if (!actorId || !projectId) {
|
||||
throw new ForbiddenException('Missing actor or project scope');
|
||||
}
|
||||
|
||||
if (input.permissions?.includes('*')) {
|
||||
return { actorId, projectId, role: 'owner', via: 'system' };
|
||||
}
|
||||
|
||||
const membership = await this.prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId, userId: actorId } },
|
||||
});
|
||||
const role = normalizeProjectRole(membership?.role);
|
||||
if (!role || !hasRequiredRole(role, input.allowedRoles)) {
|
||||
throw new ForbiddenException('Insufficient project role');
|
||||
}
|
||||
|
||||
return { actorId, projectId, role, via: 'project_member' };
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectRole(role: string | null | undefined): ProjectGovernanceRole | null {
|
||||
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'viewer') return role;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: ProjectGovernanceRole[]): boolean {
|
||||
if (allowedRoles.length === 0) return false;
|
||||
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
|
||||
return ROLE_RANK[role] >= minimumRank;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
13
apps/server/src/modules/comment/comment.module.ts
Normal file
13
apps/server/src/modules/comment/comment.module.ts
Normal file
@@ -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 {}
|
||||
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
157
apps/server/src/modules/comment/comment.service.ts
Normal file
157
apps/server/src/modules/comment/comment.service.ts
Normal file
@@ -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<string[]> {
|
||||
const ids = new Set<string>();
|
||||
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;
|
||||
}
|
||||
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DeleteCommentDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/governance/governance.module.ts
Normal file
12
apps/server/src/modules/governance/governance.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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: '新功能' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
227
apps/server/src/modules/governance/governance.service.ts
Normal file
227
apps/server/src/modules/governance/governance.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
18
apps/server/src/modules/management/management.controller.ts
Normal file
18
apps/server/src/modules/management/management.controller.ts
Normal file
@@ -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) : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
11
apps/server/src/modules/management/management.module.ts
Normal file
11
apps/server/src/modules/management/management.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
162
apps/server/src/modules/management/management.service.ts
Normal file
162
apps/server/src/modules/management/management.service.ts
Normal file
@@ -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<string[]> {
|
||||
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<string, number> {
|
||||
return rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildMemberLoads(...groups: WorkItem[][]) {
|
||||
const counts = new Map<string, number>();
|
||||
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));
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class MarkNotificationReadDto {
|
||||
@IsString()
|
||||
recipientId!: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/notification/notification.module.ts
Normal file
10
apps/server/src/modules/notification/notification.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
99
apps/server/src/modules/notification/notification.service.ts
Normal file
99
apps/server/src/modules/notification/notification.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
129
apps/server/src/modules/project-member/project-member.service.ts
Normal file
129
apps/server/src/modules/project-member/project-member.service.ts
Normal file
@@ -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<ProjectMemberMutationInput>) {
|
||||
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<ProjectMemberMutationInput, 'role'>) {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user