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.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 { 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 { 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, }, }, }); }); } /** * 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 }; } }