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:
@@ -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, number> = {
|
||||
[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<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