diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index c08eb6d..4ddbdbd 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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: [ { diff --git a/apps/server/src/modules/project-member/dto/project-member.dto.ts b/apps/server/src/modules/project-member/dto/project-member.dto.ts new file mode 100644 index 0000000..ed11650 --- /dev/null +++ b/apps/server/src/modules/project-member/dto/project-member.dto.ts @@ -0,0 +1,29 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; +import type { ProjectGovernanceRole } from '../../../common/rbac/rbac.service'; + +const PROJECT_ROLES = ['owner', 'admin', 'member', 'viewer'] as const; + +export class AddProjectMemberDto { + @IsString() + actorId!: string; + + @IsString() + userId!: string; + + @IsOptional() + @IsIn(PROJECT_ROLES) + role?: ProjectGovernanceRole; +} + +export class UpdateProjectMemberRoleDto { + @IsString() + actorId!: string; + + @IsIn(PROJECT_ROLES) + role!: ProjectGovernanceRole; +} + +export class RemoveProjectMemberDto { + @IsString() + actorId!: string; +} diff --git a/apps/server/src/modules/project-member/project-member.controller.ts b/apps/server/src/modules/project-member/project-member.controller.ts new file mode 100644 index 0000000..b53a315 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { AddProjectMemberDto, RemoveProjectMemberDto, UpdateProjectMemberRoleDto } from './dto/project-member.dto'; +import { ProjectMemberService } from './project-member.service'; + +@Controller('projects/:projectId/members') +export class ProjectMemberController { + constructor(private readonly projectMemberService: ProjectMemberService) {} + + @Get() + list(@Param('projectId') projectId: string) { + return this.projectMemberService.list(projectId); + } + + @Post() + add(@Param('projectId') projectId: string, @Body() dto: AddProjectMemberDto) { + return this.projectMemberService.add({ ...dto, projectId }); + } + + @Patch(':userId/role') + updateRole( + @Param('projectId') projectId: string, + @Param('userId') userId: string, + @Body() dto: UpdateProjectMemberRoleDto, + ) { + return this.projectMemberService.updateRole({ ...dto, projectId, userId }); + } + + @Delete(':userId') + remove( + @Param('projectId') projectId: string, + @Param('userId') userId: string, + @Body() dto: RemoveProjectMemberDto, + ) { + return this.projectMemberService.remove({ ...dto, projectId, userId }); + } +} diff --git a/apps/server/src/modules/project-member/project-member.module.ts b/apps/server/src/modules/project-member/project-member.module.ts new file mode 100644 index 0000000..4e58ff2 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CommonDomainModule } from '../../common/common-domain.module'; +import { ProjectMemberController } from './project-member.controller'; +import { ProjectMemberService } from './project-member.service'; + +@Module({ + imports: [CommonDomainModule], + controllers: [ProjectMemberController], + providers: [ProjectMemberService], + exports: [ProjectMemberService], +}) +export class ProjectMemberModule {} diff --git a/apps/server/src/modules/project-member/project-member.service.spec.ts b/apps/server/src/modules/project-member/project-member.service.spec.ts new file mode 100644 index 0000000..4c8a064 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.service.spec.ts @@ -0,0 +1,138 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { RbacService } from '../../common/rbac/rbac.service'; +import { ProjectMemberService } from './project-member.service'; + +describe('ProjectMemberService', () => { + const makeService = () => { + const prisma = { + projectMember: { + count: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + const rbac = { + assertProjectRole: jest.fn().mockResolvedValue({ role: 'owner' }), + } as unknown as RbacService; + const audit = { + record: jest.fn(), + } as unknown as AuditService; + return { + prisma, + rbac, + audit, + service: new ProjectMemberService(prisma as any, rbac, audit), + }; + }; + + it('lists project members with their user profile', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findMany.mockResolvedValue([{ id: 'pm-1', role: 'owner' }]); + + await expect(service.list('project-1')).resolves.toEqual([{ id: 'pm-1', role: 'owner' }]); + + expect(prisma.projectMember.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project-1' }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }); + }); + + it('creates a project member after admin-or-owner authorization and writes audit', async () => { + const { prisma, rbac, audit, service } = makeService(); + prisma.projectMember.create.mockResolvedValue({ id: 'pm-2', role: 'member' }); + + await service.add({ actorId: 'm-owner', projectId: 'project-1', userId: 'm-dev', role: 'member' }); + + expect((rbac.assertProjectRole as jest.Mock)).toHaveBeenCalledWith({ + actorId: 'm-owner', + projectId: 'project-1', + allowedRoles: ['admin'], + }); + expect(prisma.projectMember.create).toHaveBeenCalledWith({ + data: { projectId: 'project-1', userId: 'm-dev', role: 'member' }, + }); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + actorId: 'm-owner', + action: 'project_member.created', + resourceType: 'project_member', + projectId: 'project-1', + })); + }); + + it('rejects demoting the last project owner', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findUnique.mockResolvedValue({ + id: 'pm-owner', + projectId: 'project-1', + userId: 'm-owner', + role: 'owner', + }); + prisma.projectMember.count.mockResolvedValue(1); + + await expect(service.updateRole({ + actorId: 'm-owner', + projectId: 'project-1', + userId: 'm-owner', + role: 'admin', + })).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.projectMember.update).not.toHaveBeenCalled(); + }); + + it('rejects removing the last project owner', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findUnique.mockResolvedValue({ + id: 'pm-owner', + projectId: 'project-1', + userId: 'm-owner', + role: 'owner', + }); + prisma.projectMember.count.mockResolvedValue(1); + + await expect(service.remove({ + actorId: 'm-owner', + projectId: 'project-1', + userId: 'm-owner', + })).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.projectMember.delete).not.toHaveBeenCalled(); + }); + + it('updates roles and writes an audit diff', async () => { + const { prisma, audit, service } = makeService(); + prisma.projectMember.findUnique.mockResolvedValue({ + id: 'pm-dev', + projectId: 'project-1', + userId: 'm-dev', + role: 'member', + }); + prisma.projectMember.update.mockResolvedValue({ + id: 'pm-dev', + projectId: 'project-1', + userId: 'm-dev', + role: 'admin', + }); + + await service.updateRole({ + actorId: 'm-owner', + projectId: 'project-1', + userId: 'm-dev', + role: 'admin', + }); + + expect(prisma.projectMember.update).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId: 'project-1', userId: 'm-dev' } }, + data: { role: 'admin' }, + }); + expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({ + action: 'project_member.role_changed', + before: expect.objectContaining({ role: 'member' }), + after: expect.objectContaining({ role: 'admin' }), + })); + }); +}); diff --git a/apps/server/src/modules/project-member/project-member.service.ts b/apps/server/src/modules/project-member/project-member.service.ts new file mode 100644 index 0000000..5c90112 --- /dev/null +++ b/apps/server/src/modules/project-member/project-member.service.ts @@ -0,0 +1,129 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.service'; +import { ProjectGovernanceRole, RbacService, normalizeProjectRole } from '../../common/rbac/rbac.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface ProjectMemberMutationInput { + actorId: string; + projectId: string; + userId: string; + role?: ProjectGovernanceRole; +} + +@Injectable() +export class ProjectMemberService { + constructor( + private readonly prisma: PrismaService, + private readonly rbacService: RbacService, + private readonly auditService: AuditService, + ) {} + + list(projectId: string) { + return this.prisma.projectMember.findMany({ + where: { projectId: requireText(projectId, 'projectId') }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }); + } + + async add(input: ProjectMemberMutationInput) { + const projectId = requireText(input.projectId, 'projectId'); + const userId = requireText(input.userId, 'userId'); + const actorId = requireText(input.actorId, 'actorId'); + const role = normalizeRequiredRole(input.role ?? 'member'); + await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] }); + + const created = await this.prisma.projectMember.create({ + data: { projectId, userId, role }, + }); + await this.auditService.record({ + actorId, + action: 'project_member.created', + resourceType: 'project_member', + resourceId: created.id, + projectId, + after: created, + }); + return created; + } + + async updateRole(input: Required) { + const projectId = requireText(input.projectId, 'projectId'); + const userId = requireText(input.userId, 'userId'); + const actorId = requireText(input.actorId, 'actorId'); + const role = normalizeRequiredRole(input.role); + await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] }); + + const existing = await this.findMembership(projectId, userId); + if (existing.role === 'owner' && role !== 'owner') { + await this.assertOwnerWillRemain(projectId); + } + + const updated = await this.prisma.projectMember.update({ + where: { projectId_userId: { projectId, userId } }, + data: { role }, + }); + await this.auditService.record({ + actorId, + action: 'project_member.role_changed', + resourceType: 'project_member', + resourceId: updated.id, + projectId, + before: existing, + after: updated, + }); + return updated; + } + + async remove(input: Omit) { + const projectId = requireText(input.projectId, 'projectId'); + const userId = requireText(input.userId, 'userId'); + const actorId = requireText(input.actorId, 'actorId'); + await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] }); + + const existing = await this.findMembership(projectId, userId); + if (existing.role === 'owner') { + await this.assertOwnerWillRemain(projectId); + } + + const removed = await this.prisma.projectMember.delete({ + where: { projectId_userId: { projectId, userId } }, + }); + await this.auditService.record({ + actorId, + action: 'project_member.deleted', + resourceType: 'project_member', + resourceId: removed.id, + projectId, + before: existing, + }); + return removed; + } + + private async findMembership(projectId: string, userId: string) { + const membership = await this.prisma.projectMember.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }); + if (!membership) throw new NotFoundException('Project member not found'); + return membership; + } + + private async assertOwnerWillRemain(projectId: string) { + const ownerCount = await this.prisma.projectMember.count({ where: { projectId, role: 'owner' } }); + if (ownerCount <= 1) { + throw new BadRequestException('Cannot remove or demote the last project owner'); + } + } +} + +function normalizeRequiredRole(role: string | undefined): ProjectGovernanceRole { + const normalized = normalizeProjectRole(role); + if (!normalized) throw new BadRequestException(`Unsupported project member role: ${role}`); + return normalized; +} + +function requireText(value: string | undefined | null, field: string): string { + const normalized = value?.trim(); + if (!normalized) throw new BadRequestException(`${field} is required`); + return normalized; +}