Files
stack/apps/api/src/workspaces/workspaces.service.ts
Jason Woltje 78ff8f8e70
Some checks failed
ci/woodpecker/push/ci Pipeline failed
fix: GET workspace members endpoint (#635)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 21:53:51 +00:00

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 };
}
}