feat(api): add workspace member management endpoints (#556)
Some checks are pending
ci/woodpecker/push/api Pipeline is running
Some checks are pending
ci/woodpecker/push/api Pipeline is running
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #556.
This commit is contained in:
@@ -65,5 +65,16 @@
|
|||||||
"completed_at": ""
|
"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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
13
apps/api/src/workspaces/dto/add-member.dto.ts
Normal file
13
apps/api/src/workspaces/dto/add-member.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
export { AddMemberDto } from "./add-member.dto";
|
||||||
|
export { UpdateMemberRoleDto } from "./update-member-role.dto";
|
||||||
export { WorkspaceResponseDto } from "./workspace-response.dto";
|
export { WorkspaceResponseDto } from "./workspace-response.dto";
|
||||||
|
|||||||
10
apps/api/src/workspaces/dto/update-member-role.dto.ts
Normal file
10
apps/api/src/workspaces/dto/update-member-role.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { WorkspacesController } from "./workspaces.controller";
|
import { WorkspacesController } from "./workspaces.controller";
|
||||||
import { WorkspacesService } from "./workspaces.service";
|
import { WorkspacesService } from "./workspaces.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import { WorkspaceMemberRole } from "@prisma/client";
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
import type { AuthUser } from "@mosaic/shared";
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ describe("WorkspacesController", () => {
|
|||||||
|
|
||||||
const mockWorkspacesService = {
|
const mockWorkspacesService = {
|
||||||
getUserWorkspaces: vi.fn(),
|
getUserWorkspaces: vi.fn(),
|
||||||
|
addMember: vi.fn(),
|
||||||
|
updateMemberRole: vi.fn(),
|
||||||
|
removeMember: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUser: AuthUser = {
|
const mockUser: AuthUser = {
|
||||||
@@ -32,6 +36,10 @@ describe("WorkspacesController", () => {
|
|||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard)
|
.overrideGuard(AuthGuard)
|
||||||
.useValue({ canActivate: () => true })
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(WorkspaceGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(PermissionGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
controller = module.get<WorkspacesController>(WorkspacesController);
|
controller = module.get<WorkspacesController>(WorkspacesController);
|
||||||
@@ -72,4 +80,70 @@ describe("WorkspacesController", () => {
|
|||||||
await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { WorkspacesService } from "./workspaces.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
import type { AuthUser } from "@mosaic/shared";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import type { WorkspaceResponseDto } from "./dto";
|
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.
|
* User-scoped workspace operations.
|
||||||
@@ -22,7 +25,61 @@ export class WorkspacesController {
|
|||||||
* Auto-provisions a default workspace if the user has none.
|
* Auto-provisions a default workspace if the user has none.
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
async getUserWorkspaces(@CurrentUser() user: AuthUser): Promise<WorkspaceResponseDto[]> {
|
async getUserWorkspaces(@CurrentUser() user: AuthenticatedUser): Promise<WorkspaceResponseDto[]> {
|
||||||
return this.workspacesService.getUserWorkspaces(user.id);
|
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<WorkspaceMember> {
|
||||||
|
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<WorkspaceMember> {
|
||||||
|
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<void> {
|
||||||
|
await this.workspacesService.removeMember(workspaceId, user.id, targetUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { WorkspacesService } from "./workspaces.service";
|
import { WorkspacesService } from "./workspaces.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { WorkspaceMemberRole } from "@prisma/client";
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
describe("WorkspacesService", () => {
|
describe("WorkspacesService", () => {
|
||||||
let service: WorkspacesService;
|
let service: WorkspacesService;
|
||||||
|
|
||||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
|
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 mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
|
||||||
const mockWorkspace = {
|
const mockWorkspace = {
|
||||||
@@ -36,11 +44,18 @@ describe("WorkspacesService", () => {
|
|||||||
const mockPrismaService = {
|
const mockPrismaService = {
|
||||||
workspaceMember: {
|
workspaceMember: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
$transaction: vi.fn(),
|
$transaction: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +73,11 @@ describe("WorkspacesService", () => {
|
|||||||
service = module.get<WorkspacesService>(WorkspacesService);
|
service = module.get<WorkspacesService>(WorkspacesService);
|
||||||
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) =>
|
||||||
|
fn(mockPrismaService as unknown as typeof mockPrismaService)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getUserWorkspaces", () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import {
|
||||||
import { WorkspaceMemberRole } from "@prisma/client";
|
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 { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { WorkspaceResponseDto } from "./dto";
|
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
||||||
|
|
||||||
|
const WORKSPACE_ROLE_RANK: Record<WorkspaceMemberRole, number> = {
|
||||||
|
[WorkspaceMemberRole.GUEST]: 1,
|
||||||
|
[WorkspaceMemberRole.MEMBER]: 2,
|
||||||
|
[WorkspaceMemberRole.ADMIN]: 3,
|
||||||
|
[WorkspaceMemberRole.OWNER]: 4,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspacesService {
|
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<WorkspaceMember> {
|
||||||
|
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<WorkspaceMember> {
|
||||||
|
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<void> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user