feat(v2.7): 增加项目成员治理接口
This commit is contained in:
@@ -12,9 +12,10 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { ProjectMemberModule } from './modules/project-member/project-member.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule],
|
||||
controllers: [],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -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