From e611782deabda5a5356645a2c9b34b510b821fc1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 11:59:44 -0600 Subject: [PATCH] feat(api): add workspace member management endpoints - POST/PATCH/DELETE /api/workspaces/:id/members - Role hierarchy enforcement (cannot manage above own role) - Cannot remove last OWNER, cannot self-demote if sole owner - DTOs: AddMemberDto, UpdateMemberRoleDto with class-validator - Tests: controller + service specs pass Refs: MS21-API-003 --- .mosaic/orchestrator/mission.json | 13 +- .mosaic/orchestrator/session.lock | 8 + apps/api/src/workspaces/dto/add-member.dto.ts | 13 + apps/api/src/workspaces/dto/index.ts | 2 + .../workspaces/dto/update-member-role.dto.ts | 10 + .../workspaces/workspaces.controller.spec.ts | 74 +++++ .../src/workspaces/workspaces.controller.ts | 65 +++- .../src/workspaces/workspaces.service.spec.ts | 287 ++++++++++++++++++ apps/api/src/workspaces/workspaces.service.ts | 254 +++++++++++++++- 9 files changed, 718 insertions(+), 8 deletions(-) create mode 100644 .mosaic/orchestrator/session.lock create mode 100644 apps/api/src/workspaces/dto/add-member.dto.ts create mode 100644 apps/api/src/workspaces/dto/update-member-role.dto.ts diff --git a/.mosaic/orchestrator/mission.json b/.mosaic/orchestrator/mission.json index 8b95e42..cd0daa7 100644 --- a/.mosaic/orchestrator/mission.json +++ b/.mosaic/orchestrator/mission.json @@ -65,5 +65,16 @@ "completed_at": "" } ], - "sessions": [] + "sessions": [ + { + "session_id": "sess-001", + "runtime": "unknown", + "started_at": "2026-02-28T17:48:51Z", + "ended_at": "", + "ended_reason": "", + "milestone_at_end": "", + "tasks_completed": [], + "last_task_id": "" + } + ] } diff --git a/.mosaic/orchestrator/session.lock b/.mosaic/orchestrator/session.lock new file mode 100644 index 0000000..f283e17 --- /dev/null +++ b/.mosaic/orchestrator/session.lock @@ -0,0 +1,8 @@ +{ + "session_id": "sess-001", + "runtime": "unknown", + "pid": 2396592, + "started_at": "2026-02-28T17:48:51Z", + "project_path": "/tmp/ms21-api-003", + "milestone_id": "" +} diff --git a/apps/api/src/workspaces/dto/add-member.dto.ts b/apps/api/src/workspaces/dto/add-member.dto.ts new file mode 100644 index 0000000..0fb87c8 --- /dev/null +++ b/apps/api/src/workspaces/dto/add-member.dto.ts @@ -0,0 +1,13 @@ +import { WorkspaceMemberRole } from "@prisma/client"; +import { IsEnum, IsUUID } from "class-validator"; + +/** + * DTO for adding a user to a workspace. + */ +export class AddMemberDto { + @IsUUID("4", { message: "userId must be a valid UUID" }) + userId!: string; + + @IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" }) + role!: WorkspaceMemberRole; +} diff --git a/apps/api/src/workspaces/dto/index.ts b/apps/api/src/workspaces/dto/index.ts index 32bb49a..d2db3cb 100644 --- a/apps/api/src/workspaces/dto/index.ts +++ b/apps/api/src/workspaces/dto/index.ts @@ -1 +1,3 @@ +export { AddMemberDto } from "./add-member.dto"; +export { UpdateMemberRoleDto } from "./update-member-role.dto"; export { WorkspaceResponseDto } from "./workspace-response.dto"; diff --git a/apps/api/src/workspaces/dto/update-member-role.dto.ts b/apps/api/src/workspaces/dto/update-member-role.dto.ts new file mode 100644 index 0000000..57354ff --- /dev/null +++ b/apps/api/src/workspaces/dto/update-member-role.dto.ts @@ -0,0 +1,10 @@ +import { WorkspaceMemberRole } from "@prisma/client"; +import { IsEnum } from "class-validator"; + +/** + * DTO for updating a workspace member's role. + */ +export class UpdateMemberRoleDto { + @IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" }) + role!: WorkspaceMemberRole; +} diff --git a/apps/api/src/workspaces/workspaces.controller.spec.ts b/apps/api/src/workspaces/workspaces.controller.spec.ts index 31a04ee..4710343 100644 --- a/apps/api/src/workspaces/workspaces.controller.spec.ts +++ b/apps/api/src/workspaces/workspaces.controller.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { WorkspacesController } from "./workspaces.controller"; import { WorkspacesService } from "./workspaces.service"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { WorkspaceMemberRole } from "@prisma/client"; import type { AuthUser } from "@mosaic/shared"; @@ -12,6 +13,9 @@ describe("WorkspacesController", () => { const mockWorkspacesService = { getUserWorkspaces: vi.fn(), + addMember: vi.fn(), + updateMemberRole: vi.fn(), + removeMember: vi.fn(), }; const mockUser: AuthUser = { @@ -32,6 +36,10 @@ describe("WorkspacesController", () => { }) .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) + .overrideGuard(WorkspaceGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) .compile(); controller = module.get(WorkspacesController); @@ -72,4 +80,70 @@ describe("WorkspacesController", () => { await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error"); }); }); + + describe("POST /api/workspaces/:id/members", () => { + it("should call service with workspace id, actor id, and add member dto", async () => { + const workspaceId = "ws-1"; + const addMemberDto = { + userId: "user-2", + role: WorkspaceMemberRole.MEMBER, + }; + const mockMember = { + workspaceId, + userId: "user-2", + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-02-01"), + }; + mockWorkspacesService.addMember.mockResolvedValueOnce(mockMember); + + const result = await controller.addMember(workspaceId, addMemberDto, mockUser); + + expect(result).toEqual(mockMember); + expect(service.addMember).toHaveBeenCalledWith(workspaceId, mockUser.id, addMemberDto); + }); + }); + + describe("PATCH /api/workspaces/:id/members/:userId", () => { + it("should call service with workspace id, actor id, target user id, and role dto", async () => { + const workspaceId = "ws-1"; + const targetUserId = "user-2"; + const updateRoleDto = { + role: WorkspaceMemberRole.ADMIN, + }; + const mockMember = { + workspaceId, + userId: targetUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-02-01"), + }; + mockWorkspacesService.updateMemberRole.mockResolvedValueOnce(mockMember); + + const result = await controller.updateMemberRole( + workspaceId, + targetUserId, + updateRoleDto, + mockUser + ); + + expect(result).toEqual(mockMember); + expect(service.updateMemberRole).toHaveBeenCalledWith( + workspaceId, + mockUser.id, + targetUserId, + updateRoleDto + ); + }); + }); + + describe("DELETE /api/workspaces/:id/members/:userId", () => { + it("should call service with workspace id, actor id, and target user id", async () => { + const workspaceId = "ws-1"; + const targetUserId = "user-2"; + mockWorkspacesService.removeMember.mockResolvedValueOnce(undefined); + + await controller.removeMember(workspaceId, targetUserId, mockUser); + + expect(service.removeMember).toHaveBeenCalledWith(workspaceId, mockUser.id, targetUserId); + }); + }); }); diff --git a/apps/api/src/workspaces/workspaces.controller.ts b/apps/api/src/workspaces/workspaces.controller.ts index d23f825..a8166ad 100644 --- a/apps/api/src/workspaces/workspaces.controller.ts +++ b/apps/api/src/workspaces/workspaces.controller.ts @@ -1,9 +1,12 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common"; import { WorkspacesService } from "./workspaces.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; -import type { AuthUser } from "@mosaic/shared"; -import type { WorkspaceResponseDto } from "./dto"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Permission, RequirePermission } from "../common/decorators"; +import type { WorkspaceMember } from "@prisma/client"; +import type { AuthenticatedUser } from "../common/types/user.types"; +import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto"; /** * User-scoped workspace operations. @@ -22,7 +25,61 @@ export class WorkspacesController { * Auto-provisions a default workspace if the user has none. */ @Get() - async getUserWorkspaces(@CurrentUser() user: AuthUser): Promise { + async getUserWorkspaces(@CurrentUser() user: AuthenticatedUser): Promise { return this.workspacesService.getUserWorkspaces(user.id); } + + /** + * POST /api/workspaces/:workspaceId/members + * Add a member to a workspace with the specified role. + * Requires: ADMIN role or higher. + */ + @Post(":workspaceId/members") + @UseGuards(WorkspaceGuard, PermissionGuard) + @RequirePermission(Permission.WORKSPACE_ADMIN) + async addMember( + @Param("workspaceId") workspaceId: string, + @Body() addMemberDto: AddMemberDto, + @CurrentUser() user: AuthenticatedUser + ): Promise { + return this.workspacesService.addMember(workspaceId, user.id, addMemberDto); + } + + /** + * PATCH /api/workspaces/:workspaceId/members/:userId + * Change a member role in a workspace. + * Requires: ADMIN role or higher. + */ + @Patch(":workspaceId/members/:userId") + @UseGuards(WorkspaceGuard, PermissionGuard) + @RequirePermission(Permission.WORKSPACE_ADMIN) + async updateMemberRole( + @Param("workspaceId") workspaceId: string, + @Param("userId") targetUserId: string, + @Body() updateMemberRoleDto: UpdateMemberRoleDto, + @CurrentUser() user: AuthenticatedUser + ): Promise { + return this.workspacesService.updateMemberRole( + workspaceId, + user.id, + targetUserId, + updateMemberRoleDto + ); + } + + /** + * DELETE /api/workspaces/:workspaceId/members/:userId + * Remove a member from a workspace. + * Requires: ADMIN role or higher. + */ + @Delete(":workspaceId/members/:userId") + @UseGuards(WorkspaceGuard, PermissionGuard) + @RequirePermission(Permission.WORKSPACE_ADMIN) + async removeMember( + @Param("workspaceId") workspaceId: string, + @Param("userId") targetUserId: string, + @CurrentUser() user: AuthenticatedUser + ): Promise { + await this.workspacesService.removeMember(workspaceId, user.id, targetUserId); + } } diff --git a/apps/api/src/workspaces/workspaces.service.spec.ts b/apps/api/src/workspaces/workspaces.service.spec.ts index 2c87b0c..3de7b6f 100644 --- a/apps/api/src/workspaces/workspaces.service.spec.ts +++ b/apps/api/src/workspaces/workspaces.service.spec.ts @@ -3,11 +3,19 @@ import { Test, TestingModule } from "@nestjs/testing"; import { WorkspacesService } from "./workspaces.service"; import { PrismaService } from "../prisma/prisma.service"; import { WorkspaceMemberRole } from "@prisma/client"; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; describe("WorkspacesService", () => { let service: WorkspacesService; const mockUserId = "550e8400-e29b-41d4-a716-446655440001"; + const mockAdminUserId = "550e8400-e29b-41d4-a716-446655440010"; + const mockMemberUserId = "550e8400-e29b-41d4-a716-446655440011"; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002"; const mockWorkspace = { @@ -36,11 +44,18 @@ describe("WorkspacesService", () => { const mockPrismaService = { workspaceMember: { findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), }, workspace: { create: vi.fn(), }, + user: { + findUnique: vi.fn(), + }, $transaction: vi.fn(), }; @@ -58,6 +73,11 @@ describe("WorkspacesService", () => { service = module.get(WorkspacesService); vi.clearAllMocks(); + + mockPrismaService.$transaction.mockImplementation( + async (fn: (tx: typeof mockPrismaService) => Promise) => + fn(mockPrismaService as unknown as typeof mockPrismaService) + ); }); describe("getUserWorkspaces", () => { @@ -226,4 +246,271 @@ describe("WorkspacesService", () => { ); }); }); + + describe("addMember", () => { + const addMemberDto = { + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + }; + + it("should add a new member to the workspace", async () => { + const createdMembership = { + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-02-02"), + }; + mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId }); + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockAdminUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce(null); + mockPrismaService.workspaceMember.create.mockResolvedValueOnce(createdMembership); + + const result = await service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto); + + expect(result).toEqual(createdMembership); + expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + }, + }); + }); + + it("should throw NotFoundException when user does not exist", async () => { + mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockAdminUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-01"), + }); + mockPrismaService.user.findUnique.mockResolvedValueOnce(null); + + await expect( + service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto) + ).rejects.toThrow(NotFoundException); + }); + + it("should throw ConflictException when user is already a member", async () => { + mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockAdminUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-01"), + }); + mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId }); + mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-01-02"), + }); + + await expect( + service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto) + ).rejects.toThrow(ConflictException); + }); + + it("should throw ForbiddenException when admin tries to assign OWNER role", async () => { + mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockAdminUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-01"), + }); + + await expect( + service.addMember(mockWorkspaceId, mockAdminUserId, { + userId: mockMemberUserId, + role: WorkspaceMemberRole.OWNER, + }) + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe("updateMemberRole", () => { + it("should update a member role", async () => { + const updatedMembership = { + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-02"), + }; + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-01-02"), + }); + mockPrismaService.workspaceMember.update.mockResolvedValueOnce(updatedMembership); + + const result = await service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, { + role: WorkspaceMemberRole.ADMIN, + }); + + expect(result).toEqual(updatedMembership); + expect(mockPrismaService.workspaceMember.update).toHaveBeenCalledWith({ + where: { + workspaceId_userId: { + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + }, + }, + data: { + role: WorkspaceMemberRole.ADMIN, + }, + }); + }); + + it("should throw NotFoundException when target member does not exist", async () => { + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce(null); + + await expect( + service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, { + role: WorkspaceMemberRole.ADMIN, + }) + ).rejects.toThrow(NotFoundException); + }); + + it("should throw BadRequestException when sole owner attempts self-demotion", async () => { + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }); + mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1); + + await expect( + service.updateMemberRole(mockWorkspaceId, mockUserId, mockUserId, { + role: WorkspaceMemberRole.ADMIN, + }) + ).rejects.toThrow(BadRequestException); + }); + + it("should throw ForbiddenException when actor tries to change role of higher-ranked member", async () => { + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockAdminUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }); + + await expect( + service.updateMemberRole(mockWorkspaceId, mockAdminUserId, mockUserId, { + role: WorkspaceMemberRole.MEMBER, + }) + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe("removeMember", () => { + it("should remove a workspace member", async () => { + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-01-02"), + }); + mockPrismaService.workspaceMember.delete.mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-01-02"), + }); + + await service.removeMember(mockWorkspaceId, mockUserId, mockMemberUserId); + + expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({ + where: { + workspaceId_userId: { + workspaceId: mockWorkspaceId, + userId: mockMemberUserId, + }, + }, + }); + }); + + it("should throw BadRequestException when trying to remove the last owner", async () => { + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }); + mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1); + + await expect(service.removeMember(mockWorkspaceId, mockUserId, mockUserId)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw ForbiddenException when admin attempts to remove an owner", async () => { + mockPrismaService.workspaceMember.findUnique + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockAdminUserId, + role: WorkspaceMemberRole.ADMIN, + joinedAt: new Date("2026-01-01"), + }) + .mockResolvedValueOnce({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + }); + + await expect( + service.removeMember(mockWorkspaceId, mockAdminUserId, mockUserId) + ).rejects.toThrow(ForbiddenException); + }); + }); }); diff --git a/apps/api/src/workspaces/workspaces.service.ts b/apps/api/src/workspaces/workspaces.service.ts index 247932e..18a5c9a 100644 --- a/apps/api/src/workspaces/workspaces.service.ts +++ b/apps/api/src/workspaces/workspaces.service.ts @@ -1,7 +1,22 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { WorkspaceMemberRole } from "@prisma/client"; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { Prisma, WorkspaceMemberRole } from "@prisma/client"; +import type { WorkspaceMember } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; -import type { WorkspaceResponseDto } from "./dto"; +import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto"; + +const WORKSPACE_ROLE_RANK: Record = { + [WorkspaceMemberRole.GUEST]: 1, + [WorkspaceMemberRole.MEMBER]: 2, + [WorkspaceMemberRole.ADMIN]: 3, + [WorkspaceMemberRole.OWNER]: 4, +}; @Injectable() export class WorkspacesService { @@ -94,4 +109,237 @@ export class WorkspacesService { }, ]; } + + /** + * Add a member to a workspace. + */ + async addMember( + workspaceId: string, + actorUserId: string, + addMemberDto: AddMemberDto + ): Promise { + const actorMembership = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: actorUserId, + }, + }, + select: { + role: true, + }, + }); + + if (!actorMembership) { + throw new ForbiddenException("You are not a member of this workspace"); + } + + this.assertCanAssignRole(actorMembership.role, addMemberDto.role); + + const user = await this.prisma.user.findUnique({ + where: { id: addMemberDto.userId }, + select: { id: true }, + }); + + if (!user) { + throw new NotFoundException(`User with ID ${addMemberDto.userId} not found`); + } + + const existingMembership = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: addMemberDto.userId, + }, + }, + select: { + workspaceId: true, + userId: true, + }, + }); + + if (existingMembership) { + throw new ConflictException("User is already a member of this workspace"); + } + + try { + return await this.prisma.workspaceMember.create({ + data: { + workspaceId, + userId: addMemberDto.userId, + role: addMemberDto.role, + }, + }); + } catch (error) { + if (this.isUniqueConstraintError(error)) { + throw new ConflictException("User is already a member of this workspace"); + } + throw error; + } + } + + /** + * Update the role of an existing workspace member. + */ + async updateMemberRole( + workspaceId: string, + actorUserId: string, + targetUserId: string, + updateMemberRoleDto: UpdateMemberRoleDto + ): Promise { + return this.prisma.$transaction(async (tx) => { + const actorMembership = await tx.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: actorUserId, + }, + }, + select: { + role: true, + }, + }); + + if (!actorMembership) { + throw new ForbiddenException("You are not a member of this workspace"); + } + + const targetMembership = await tx.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: targetUserId, + }, + }, + select: { + role: true, + }, + }); + + if (!targetMembership) { + throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`); + } + + this.assertCanManageTargetMember(actorMembership.role, targetMembership.role); + this.assertCanAssignRole(actorMembership.role, updateMemberRoleDto.role); + + if (targetMembership.role === WorkspaceMemberRole.OWNER) { + const isDemotion = updateMemberRoleDto.role !== WorkspaceMemberRole.OWNER; + if (isDemotion) { + const ownerCount = await tx.workspaceMember.count({ + where: { + workspaceId, + role: WorkspaceMemberRole.OWNER, + }, + }); + if (ownerCount <= 1) { + if (actorUserId === targetUserId) { + throw new BadRequestException("Cannot self-demote if you are the sole owner"); + } + throw new BadRequestException("Cannot remove the last owner from a workspace"); + } + } + } + + return tx.workspaceMember.update({ + where: { + workspaceId_userId: { + workspaceId, + userId: targetUserId, + }, + }, + data: { + role: updateMemberRoleDto.role, + }, + }); + }); + } + + /** + * Remove a member from a workspace. + */ + async removeMember( + workspaceId: string, + actorUserId: string, + targetUserId: string + ): Promise { + await this.prisma.$transaction(async (tx) => { + const actorMembership = await tx.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: actorUserId, + }, + }, + select: { + role: true, + }, + }); + + if (!actorMembership) { + throw new ForbiddenException("You are not a member of this workspace"); + } + + const targetMembership = await tx.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: targetUserId, + }, + }, + select: { + role: true, + }, + }); + + if (!targetMembership) { + throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`); + } + + this.assertCanManageTargetMember(actorMembership.role, targetMembership.role); + + if (targetMembership.role === WorkspaceMemberRole.OWNER) { + const ownerCount = await tx.workspaceMember.count({ + where: { + workspaceId, + role: WorkspaceMemberRole.OWNER, + }, + }); + if (ownerCount <= 1) { + throw new BadRequestException("Cannot remove the last owner from a workspace"); + } + } + + await tx.workspaceMember.delete({ + where: { + workspaceId_userId: { + workspaceId, + userId: targetUserId, + }, + }, + }); + }); + } + + private assertCanAssignRole( + actorRole: WorkspaceMemberRole, + requestedRole: WorkspaceMemberRole + ): void { + if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[requestedRole]) { + throw new ForbiddenException("You cannot assign a role higher than your own"); + } + } + + private assertCanManageTargetMember( + actorRole: WorkspaceMemberRole, + targetRole: WorkspaceMemberRole + ): void { + if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[targetRole]) { + throw new ForbiddenException("You cannot manage a member with a higher role"); + } + } + + private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"; + } } -- 2.49.1