Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
369 lines
10 KiB
TypeScript
369 lines
10 KiB
TypeScript
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 { 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 {
|
|
private readonly logger = new Logger(WorkspacesService.name);
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/**
|
|
* Get all workspaces the user is a member of.
|
|
*
|
|
* Auto-provisioning: if the user has no workspace memberships (e.g. fresh
|
|
* signup via BetterAuth), a default workspace is created atomically and
|
|
* returned. This is the only call site for workspace bootstrapping.
|
|
*/
|
|
async getUserWorkspaces(userId: string): Promise<WorkspaceResponseDto[]> {
|
|
const memberships = await this.prisma.workspaceMember.findMany({
|
|
where: { userId },
|
|
include: {
|
|
workspace: {
|
|
select: { id: true, name: true, ownerId: true, createdAt: true },
|
|
},
|
|
},
|
|
orderBy: { joinedAt: "asc" },
|
|
});
|
|
|
|
if (memberships.length > 0) {
|
|
return memberships.map((m) => ({
|
|
id: m.workspace.id,
|
|
name: m.workspace.name,
|
|
ownerId: m.workspace.ownerId,
|
|
role: m.role,
|
|
createdAt: m.workspace.createdAt,
|
|
}));
|
|
}
|
|
|
|
// Auto-provision a default workspace for new users.
|
|
// Re-query inside the transaction to guard against concurrent requests
|
|
// both seeing zero memberships and creating duplicate workspaces.
|
|
this.logger.log(`Auto-provisioning default workspace for user ${userId}`);
|
|
|
|
const workspace = await this.prisma.$transaction(async (tx) => {
|
|
const existing = await tx.workspaceMember.findFirst({
|
|
where: { userId },
|
|
include: {
|
|
workspace: {
|
|
select: { id: true, name: true, ownerId: true, createdAt: true },
|
|
},
|
|
},
|
|
});
|
|
if (existing) {
|
|
return { ...existing.workspace, alreadyExisted: true as const };
|
|
}
|
|
|
|
const created = await tx.workspace.create({
|
|
data: {
|
|
name: "My Workspace",
|
|
ownerId: userId,
|
|
settings: {},
|
|
},
|
|
});
|
|
await tx.workspaceMember.create({
|
|
data: {
|
|
workspaceId: created.id,
|
|
userId,
|
|
role: WorkspaceMemberRole.OWNER,
|
|
},
|
|
});
|
|
return { ...created, alreadyExisted: false as const };
|
|
});
|
|
|
|
if (workspace.alreadyExisted) {
|
|
return [
|
|
{
|
|
id: workspace.id,
|
|
name: workspace.name,
|
|
ownerId: workspace.ownerId,
|
|
role: WorkspaceMemberRole.OWNER,
|
|
createdAt: workspace.createdAt,
|
|
},
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: workspace.id,
|
|
name: workspace.name,
|
|
ownerId: workspace.ownerId,
|
|
role: WorkspaceMemberRole.OWNER,
|
|
createdAt: workspace.createdAt,
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get members of a workspace.
|
|
*/
|
|
async getMembers(workspaceId: string) {
|
|
return this.prisma.workspaceMember.findMany({
|
|
where: { workspaceId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, createdAt: true } },
|
|
},
|
|
orderBy: { joinedAt: "asc" },
|
|
});
|
|
}
|
|
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";
|
|
}
|
|
|
|
async getStats(
|
|
workspaceId: string
|
|
): Promise<{ memberCount: number; projectCount: number; domainCount: number }> {
|
|
const [memberCount, projectCount, domainCount] = await Promise.all([
|
|
this.prisma.workspaceMember.count({ where: { workspaceId } }),
|
|
this.prisma.project.count({ where: { workspaceId } }),
|
|
this.prisma.domain.count({ where: { workspaceId } }),
|
|
]);
|
|
return { memberCount, projectCount, domainCount };
|
|
}
|
|
}
|