import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { removeWorkspaceMemberContract } from '@/lib/api/contracts/invitations' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx, WorkspaceBillingAccountRemovalError, } from '@/lib/workspaces/utils' const logger = createLogger('WorkspaceMemberAPI') // DELETE /api/workspaces/members/[id] - Remove a member from a workspace export const DELETE = withRouteHandler( async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } try { const parsed = await parseRequest(removeWorkspaceMemberContract, req, context) if (!parsed.success) return parsed.response const { id: userId } = parsed.data.params const { workspaceId } = parsed.data.body const workspaceRow = await db .select({ ownerId: workspace.ownerId, billedAccountUserId: workspace.billedAccountUserId, organizationId: workspace.organizationId, }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1) if (!workspaceRow.length) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } if (workspaceRow[0].billedAccountUserId === userId) { return NextResponse.json( { error: 'Cannot remove the workspace billing account. Please reassign billing first.' }, { status: 400 } ) } // Check if the user to be removed actually has permissions for this workspace const userPermission = await db .select() .from(permissions) .where( and( eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId) ) ) .then((rows) => rows[0]) const isRemovingWorkspaceOwner = workspaceRow[0].ownerId === userId const isOwnerOnlyRemoval = isRemovingWorkspaceOwner && !userPermission if (!userPermission && !isOwnerOnlyRemoval) { return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) } // Check if current user has admin access to this workspace const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) const isSelf = userId === session.user.id if (!hasAdminAccess && !isSelf) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } // Removing the workspace owner is allowed for any admin: ownership transfers // to the billing account in the transaction below. The billing account itself // stays protected by the guard above (and personal workspaces, where owner == // billing account, are blocked there). // Prevent removing yourself if you're the last admin if (isSelf && userPermission?.permissionType === 'admin' && !isRemovingWorkspaceOwner) { const otherAdmins = await db .select() .from(permissions) .where( and( eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId), eq(permissions.permissionType, 'admin') ) ) .then((rows) => rows.filter((row) => row.userId !== session.user.id)) if (otherAdmins.length === 0) { return NextResponse.json( { error: 'Cannot remove the last admin from a workspace' }, { status: 400 } ) } } const organizationId = workspaceRow[0].organizationId const { ownershipTransferred, workflowOwnershipReassignment } = await db.transaction( async (tx) => { const didTransferOwnership = await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({ tx, workspaceId, departingUserId: userId, }) const workflowOwnershipReassignment = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({ tx, workspaceIds: [workspaceId], departingUserId: userId, }) if (workflowOwnershipReassignment.unresolved.length > 0) { throw new WorkspaceBillingAccountRemovalError() } await tx .delete(permissions) .where( and( eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId) ) ) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment } } ) /** * Seats are tied to organization membership (one per member), so a * single-workspace removal only drops a seat when it leaves the member * with no access to any of the org's workspaces — at which point their * org membership is removed too. Members still in other org workspaces * keep their membership and seat. */ let organizationRemoval = false let seatReduction: Awaited> | null = null if (organizationId && userId !== workspaceRow[0].billedAccountUserId) { const [orgMembership] = await db .select({ id: member.id }) .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) .limit(1) if (orgMembership) { /** * Remove the org membership + seat only when this is the member's last * access to any org workspace. The remaining-access check and the * deletion happen atomically under a `(user, org)` advisory lock inside * `removeUserFromOrganization` (`requireNoOrgWorkspaceAccess`), so a * concurrent invite acceptance can't be raced into a "workspace access * but no org membership" state. */ const removal = await removeUserFromOrganization({ userId, organizationId, memberId: orgMembership.id, requireNoOrgWorkspaceAccess: true, }) if (removal.success && removal.removed) { organizationRemoval = true try { seatReduction = await reconcileOrganizationSeats({ organizationId, reason: 'member-removed', actorId: session.user.id, }) } catch (seatError) { logger.error('Failed to reduce seats after workspace member removal', { organizationId, workspaceId, removedUserId: userId, error: seatError, }) } } else if (!removal.success) { logger.error('Failed to remove org membership after last workspace removal', { organizationId, workspaceId, removedUserId: userId, error: removal.error, }) } } } captureServerEvent( session.user.id, 'workspace_member_removed', { workspace_id: workspaceId, is_self_removal: isSelf }, { groups: { workspace: workspaceId } } ) recordAudit({ workspaceId, actorId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, action: AuditAction.MEMBER_REMOVED, resourceType: AuditResourceType.WORKSPACE, resourceId: workspaceId, description: isSelf ? organizationRemoval ? 'Left the organization' : 'Left the workspace' : organizationRemoval ? `Removed member ${userId} from the organization` : `Removed member ${userId} from the workspace`, metadata: { removedUserId: userId, removedUserRole: userPermission?.permissionType ?? 'owner', selfRemoval: isSelf, ownershipTransferred, workflowOwnershipReassignment, organizationRemoval, seatReduction, }, request: req, }) return NextResponse.json({ success: true }) } catch (error) { if (error instanceof WorkspaceBillingAccountRemovalError) { return NextResponse.json({ error: error.message }, { status: 400 }) } logger.error('Error removing workspace member:', error) return NextResponse.json({ error: 'Failed to remove workspace member' }, { status: 500 }) } } )