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 { HealthModule } from './modules/health/health.module';
|
||||||
import { NotificationModule } from './modules/notification/notification.module';
|
import { NotificationModule } from './modules/notification/notification.module';
|
||||||
import { CommentModule } from './modules/comment/comment.module';
|
import { CommentModule } from './modules/comment/comment.module';
|
||||||
|
import { ProjectMemberModule } from './modules/project-member/project-member.module';
|
||||||
|
|
||||||
@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: [],
|
controllers: [],
|
||||||
providers: [
|
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