chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { randomItem } from '@sim/utils/random'
|
||||
import { hexToRgb } from '@/lib/colors'
|
||||
|
||||
/** Color palette for workspace accents. */
|
||||
export const WORKSPACE_COLORS = [
|
||||
'#2ABBF8', // Blue
|
||||
'#22c55e', // Green
|
||||
'#FFCC02', // Yellow
|
||||
'#a855f7', // Purple
|
||||
'#f97316', // Orange
|
||||
'#14b8a6', // Teal
|
||||
'#ff6b6b', // Coral
|
||||
] as const
|
||||
|
||||
/** Picks a random workspace color from the hero palette. */
|
||||
export function getRandomWorkspaceColor(): string {
|
||||
return randomItem(WORKSPACE_COLORS)
|
||||
}
|
||||
|
||||
const APP_COLORS = [
|
||||
{ from: '#4F46E5', to: '#7C3AED' }, // indigo to purple
|
||||
{ from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia
|
||||
{ from: '#EC4899', to: '#F97316' }, // pink to orange
|
||||
{ from: '#14B8A6', to: '#10B981' }, // teal to emerald
|
||||
{ from: '#6366F1', to: '#8B5CF6' }, // indigo to violet
|
||||
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
|
||||
]
|
||||
|
||||
/**
|
||||
* User color palette matching terminal.tsx RUN_ID_COLORS
|
||||
* These colors are used consistently across cursors, avatars, and terminal run IDs
|
||||
*/
|
||||
export const USER_COLORS = [
|
||||
'#4ADE80', // Green
|
||||
'#F472B6', // Pink
|
||||
'#60C5FF', // Blue
|
||||
'#FF8533', // Orange
|
||||
'#C084FC', // Purple
|
||||
'#FCD34D', // Yellow
|
||||
] as const
|
||||
|
||||
interface PresenceColorPalette {
|
||||
gradient: string
|
||||
accentColor: string
|
||||
baseColor: string
|
||||
}
|
||||
|
||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/
|
||||
|
||||
function hashIdentifier(identifier: string | number): number {
|
||||
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
||||
return Math.abs(Math.trunc(identifier))
|
||||
}
|
||||
|
||||
if (typeof identifier === 'string') {
|
||||
return Math.abs(Array.from(identifier).reduce((acc, char) => acc + char.charCodeAt(0), 0))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function withAlpha(hexColor: string, alpha: number): string {
|
||||
if (!HEX_COLOR_REGEX.test(hexColor)) {
|
||||
return hexColor
|
||||
}
|
||||
|
||||
const { r, g, b } = hexToRgb(hexColor)
|
||||
return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})`
|
||||
}
|
||||
|
||||
function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string {
|
||||
const rotation = (rotationSeed * 25) % 360
|
||||
return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})`
|
||||
}
|
||||
|
||||
export function getPresenceColors(
|
||||
identifier: string | number,
|
||||
explicitColor?: string
|
||||
): PresenceColorPalette {
|
||||
const paletteIndex = hashIdentifier(identifier)
|
||||
|
||||
if (explicitColor) {
|
||||
const normalizedColor = explicitColor.trim()
|
||||
const lighterShade = HEX_COLOR_REGEX.test(normalizedColor)
|
||||
? withAlpha(normalizedColor, 0.85)
|
||||
: normalizedColor
|
||||
|
||||
return {
|
||||
gradient: buildGradient(lighterShade, normalizedColor, paletteIndex),
|
||||
accentColor: normalizedColor,
|
||||
baseColor: lighterShade,
|
||||
}
|
||||
}
|
||||
|
||||
const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length]
|
||||
|
||||
return {
|
||||
gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex),
|
||||
accentColor: colorPair.to,
|
||||
baseColor: colorPair.from,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a consistent color for a user based on their ID.
|
||||
* The same user will always get the same color across cursors, avatars, and terminal.
|
||||
*
|
||||
* @param userId - The unique user identifier
|
||||
* @returns A hex color string
|
||||
*/
|
||||
export function getUserColor(userId: string): string {
|
||||
const hash = hashIdentifier(userId)
|
||||
return USER_COLORS[hash % USER_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable mapping of user IDs to color indices for a list of users.
|
||||
* Useful when you need to maintain consistent color assignments across renders.
|
||||
*
|
||||
* @param userIds - Array of user IDs to map
|
||||
* @returns Map of user ID to color index
|
||||
*/
|
||||
export function createUserColorMap(userIds: string[]): Map<string, number> {
|
||||
const colorMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!colorMap.has(userId)) {
|
||||
colorMap.set(userId, colorIndex++)
|
||||
}
|
||||
}
|
||||
|
||||
return colorMap
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSelect, mockTransaction, mockArchiveWorkflowsForWorkspace } = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockTransaction: vi.fn(),
|
||||
mockArchiveWorkflowsForWorkspace: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
transaction: mockTransaction,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/lifecycle', () => ({
|
||||
archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
import { archiveWorkspace } from './lifecycle'
|
||||
|
||||
function createUpdateChain() {
|
||||
return {
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('archives workspace and dependent resources', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace 1',
|
||||
ownerId: 'user-1',
|
||||
archivedAt: null,
|
||||
})
|
||||
mockArchiveWorkflowsForWorkspace.mockResolvedValue(2)
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'server-1' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockImplementation(() => createUpdateChain()),
|
||||
delete: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}
|
||||
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: true,
|
||||
workspaceName: 'Workspace 1',
|
||||
})
|
||||
expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', {
|
||||
requestId: 'req-1',
|
||||
})
|
||||
expect(tx.update).toHaveBeenCalledTimes(10)
|
||||
expect(tx.delete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is idempotent for already archived workspaces', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace 1',
|
||||
ownerId: 'user-1',
|
||||
archivedAt: new Date(),
|
||||
})
|
||||
|
||||
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: false,
|
||||
workspaceName: 'Workspace 1',
|
||||
})
|
||||
expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', {
|
||||
requestId: 'req-1',
|
||||
})
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
apiKey,
|
||||
document,
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
knowledgeBase,
|
||||
knowledgeConnector,
|
||||
mcpServers,
|
||||
userTableDefinitions,
|
||||
workflowMcpServer,
|
||||
workflowSchedule,
|
||||
workspace,
|
||||
workspaceFiles,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceLifecycle')
|
||||
|
||||
interface ArchiveWorkspaceOptions {
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export async function archiveWorkspace(
|
||||
workspaceId: string,
|
||||
options: ArchiveWorkspaceOptions
|
||||
): Promise<{ archived: boolean; workspaceName?: string }> {
|
||||
const workspaceRecord = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
||||
|
||||
if (!workspaceRecord) {
|
||||
return { archived: false }
|
||||
}
|
||||
|
||||
if (workspaceRecord.archivedAt) {
|
||||
await archiveWorkflowsForWorkspace(workspaceId, options)
|
||||
return { archived: false, workspaceName: workspaceRecord.name }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const workflowMcpServerIds = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(knowledgeBase)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt)))
|
||||
|
||||
const workspaceKbIds = await tx
|
||||
.select({ id: knowledgeBase.id })
|
||||
.from(knowledgeBase)
|
||||
.where(eq(knowledgeBase.workspaceId, workspaceId))
|
||||
|
||||
const knowledgeBaseIds = workspaceKbIds.map((entry) => entry.id)
|
||||
if (knowledgeBaseIds.length > 0) {
|
||||
await tx
|
||||
.update(document)
|
||||
.set({ archivedAt: now })
|
||||
.where(
|
||||
and(
|
||||
inArray(document.knowledgeBaseId, knowledgeBaseIds),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
await tx
|
||||
.update(knowledgeConnector)
|
||||
.set({ archivedAt: now, status: 'paused', updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeConnector.knowledgeBaseId, knowledgeBaseIds),
|
||||
isNull(knowledgeConnector.archivedAt),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(userTableDefinitions)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(userTableDefinitions.workspaceId, workspaceId),
|
||||
isNull(userTableDefinitions.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
await tx
|
||||
.update(workspaceFiles)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
})
|
||||
.where(and(eq(workspaceFiles.workspaceId, workspaceId), isNull(workspaceFiles.deletedAt)))
|
||||
|
||||
await tx
|
||||
.update(invitation)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.status, 'pending'),
|
||||
sql`${invitation.id} IN (
|
||||
SELECT ${invitationWorkspaceGrant.invitationId}
|
||||
FROM ${invitationWorkspaceGrant}
|
||||
WHERE ${invitationWorkspaceGrant.workspaceId} = ${workspaceId}
|
||||
)`
|
||||
)
|
||||
)
|
||||
|
||||
await tx
|
||||
.delete(apiKey)
|
||||
.where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace')))
|
||||
|
||||
await tx
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
isPublic: false,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
|
||||
await tx
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
enabled: false,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
|
||||
|
||||
await tx
|
||||
.update(workflowSchedule)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
status: 'disabled',
|
||||
nextRunAt: null,
|
||||
lastQueuedAt: null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
await tx
|
||||
.update(workspace)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
})
|
||||
|
||||
await archiveWorkflowsForWorkspace(workspaceId, options)
|
||||
|
||||
logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`)
|
||||
|
||||
await mcpService.clearCache(workspaceId).catch(() => undefined)
|
||||
|
||||
if (mcpPubSub && workflowMcpServerIds.length > 0) {
|
||||
for (const server of workflowMcpServerIds) {
|
||||
mcpPubSub.publishWorkflowToolsChanged({
|
||||
serverId: server.id,
|
||||
workspaceId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
workspaceName: workspaceRecord.name,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Utility functions for generating names for workspaces and folders
|
||||
*/
|
||||
|
||||
import { randomItem } from '@sim/utils/random'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { type FolderApi, listFoldersContract } from '@/lib/api/contracts/folders'
|
||||
|
||||
interface NameableEntity {
|
||||
name: string
|
||||
}
|
||||
|
||||
const WORKSPACE_NOUNS = [
|
||||
'Pulsar',
|
||||
'Quasar',
|
||||
'Nebula',
|
||||
'Nova',
|
||||
'Cosmos',
|
||||
'Orion',
|
||||
'Vega',
|
||||
'Zenith',
|
||||
'Horizon',
|
||||
'Eclipse',
|
||||
'Aurora',
|
||||
'Photon',
|
||||
'Vertex',
|
||||
'Nexus',
|
||||
'Solaris',
|
||||
'Andromeda',
|
||||
'Phoenix',
|
||||
'Polaris',
|
||||
'Sirius',
|
||||
'Altair',
|
||||
'Meridian',
|
||||
'Titan',
|
||||
'Apex',
|
||||
'Aether',
|
||||
'Voyager',
|
||||
'Beacon',
|
||||
'Sentinel',
|
||||
'Pioneer',
|
||||
'Equinox',
|
||||
'Solstice',
|
||||
'Corona',
|
||||
'Stellar',
|
||||
'Helix',
|
||||
'Prism',
|
||||
'Axiom',
|
||||
'Boson',
|
||||
'Cygnus',
|
||||
'Draco',
|
||||
'Lyra',
|
||||
'Aquila',
|
||||
'Perseus',
|
||||
'Pegasus',
|
||||
'Triton',
|
||||
'Callisto',
|
||||
'Europa',
|
||||
'Oberon',
|
||||
'Tachyon',
|
||||
'Neutron',
|
||||
'Graviton',
|
||||
'Parallax',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Generates the next incremental name for entities following pattern: "{prefix} {number}"
|
||||
*
|
||||
* @param existingEntities - Array of entities with name property
|
||||
* @param prefix - Prefix for the name (e.g., "Folder", "Subfolder")
|
||||
* @returns Next available name (e.g., "Folder 3")
|
||||
*/
|
||||
export function generateIncrementalName<T extends NameableEntity>(
|
||||
existingEntities: T[],
|
||||
prefix: string
|
||||
): string {
|
||||
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} (\\d+)$`)
|
||||
|
||||
const existingNumbers = existingEntities
|
||||
.map((entity) => entity.name.match(pattern))
|
||||
.filter((match) => match !== null)
|
||||
.map((match) => Number.parseInt(match![1], 10))
|
||||
|
||||
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
|
||||
return `${prefix} ${nextNumber}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random cosmos-themed workspace name
|
||||
*/
|
||||
export function generateWorkspaceName(): string {
|
||||
return randomItem(WORKSPACE_NOUNS)
|
||||
}
|
||||
|
||||
async function fetchWorkspaceFolders(workspaceId: string): Promise<FolderApi[]> {
|
||||
const { folders } = await requestJson(listFoldersContract, {
|
||||
query: { workspaceId },
|
||||
})
|
||||
return folders
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next folder name for a workspace
|
||||
*/
|
||||
export async function generateFolderName(workspaceId: string): Promise<string> {
|
||||
const folders = await fetchWorkspaceFolders(workspaceId)
|
||||
|
||||
const rootFolders = folders.filter((folder) => folder.parentId === null)
|
||||
|
||||
return generateIncrementalName(rootFolders, 'Folder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next subfolder name for a parent folder
|
||||
*/
|
||||
async function generateSubfolderName(workspaceId: string, parentFolderId: string): Promise<string> {
|
||||
const folders = await fetchWorkspaceFolders(workspaceId)
|
||||
|
||||
const subfolders = folders.filter((folder) => folder.parentId === parentFolderId)
|
||||
|
||||
return generateIncrementalName(subfolders, 'Subfolder')
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockDbResults,
|
||||
mockUpdateWhere,
|
||||
mockUpdateSet,
|
||||
mockDbUpdate,
|
||||
mockOnConflictDoUpdate,
|
||||
mockInsertValues,
|
||||
mockDbInsert,
|
||||
mockEnsureUserInOrganization,
|
||||
mockSyncUsageLimitsFromSubscription,
|
||||
mockReapplyPaidOrgJoinBillingForExistingMember,
|
||||
} = vi.hoisted(() => {
|
||||
const mockDbResults: { value: any[] } = { value: [] }
|
||||
const mockUpdateWhere = vi.fn().mockResolvedValue(undefined)
|
||||
const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere })
|
||||
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
||||
const mockOnConflictDoUpdate = vi.fn().mockResolvedValue(undefined)
|
||||
const mockInsertValues = vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: mockOnConflictDoUpdate,
|
||||
})
|
||||
const mockDbInsert = vi.fn().mockReturnValue({ values: mockInsertValues })
|
||||
const mockEnsureUserInOrganization = vi.fn()
|
||||
const mockSyncUsageLimitsFromSubscription = vi.fn().mockResolvedValue(undefined)
|
||||
const mockReapplyPaidOrgJoinBillingForExistingMember = vi.fn().mockResolvedValue({
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
})
|
||||
|
||||
return {
|
||||
mockDbResults,
|
||||
mockUpdateWhere,
|
||||
mockUpdateSet,
|
||||
mockDbUpdate,
|
||||
mockOnConflictDoUpdate,
|
||||
mockInsertValues,
|
||||
mockDbInsert,
|
||||
mockEnsureUserInOrganization,
|
||||
mockSyncUsageLimitsFromSubscription,
|
||||
mockReapplyPaidOrgJoinBillingForExistingMember,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => {
|
||||
const selectImpl = vi.fn().mockImplementation(() => {
|
||||
const chain: any = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || []))
|
||||
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
|
||||
const result = mockDbResults.value.shift() || []
|
||||
return Promise.resolve(callback ? callback(result) : result)
|
||||
})
|
||||
return chain
|
||||
})
|
||||
const txObject = {
|
||||
select: selectImpl,
|
||||
update: mockDbUpdate,
|
||||
insert: mockDbInsert,
|
||||
}
|
||||
return {
|
||||
db: {
|
||||
select: selectImpl,
|
||||
update: mockDbUpdate,
|
||||
insert: mockDbInsert,
|
||||
transaction: vi.fn(async (fn: (tx: typeof txObject) => unknown) => fn(txObject)),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db/schema', () => schemaMock)
|
||||
|
||||
vi.mock('@/lib/billing/organizations/membership', () => ({
|
||||
ensureUserInOrganization: mockEnsureUserInOrganization,
|
||||
reapplyPaidOrgJoinBillingForExistingMember: mockReapplyPaidOrgJoinBillingForExistingMember,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: vi.fn().mockReturnValue('generated-id'),
|
||||
generateShortId: vi.fn().mockReturnValue('short-id'),
|
||||
}))
|
||||
|
||||
import {
|
||||
attachOwnedWorkspacesToOrganization,
|
||||
detachOrganizationWorkspaces,
|
||||
WorkspaceOrganizationMembershipConflictError,
|
||||
} from '@/lib/workspaces/organization-workspaces'
|
||||
|
||||
describe('organization workspace helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbResults.value = []
|
||||
mockEnsureUserInOrganization.mockReset()
|
||||
mockSyncUsageLimitsFromSubscription.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('attaches owned workspaces to an organization and syncs existing members', async () => {
|
||||
mockDbResults.value = [
|
||||
[{ id: 'ws-1' }, { id: 'ws-2' }],
|
||||
[{ userId: 'owner-1' }],
|
||||
[{ userId: 'owner-1' }, { userId: 'member-1' }],
|
||||
[{ userId: 'owner-1', organizationId: 'org-1' }],
|
||||
]
|
||||
mockEnsureUserInOrganization
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
alreadyMember: true,
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
alreadyMember: false,
|
||||
memberId: 'member-1',
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await attachOwnedWorkspacesToOrganization({
|
||||
ownerUserId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.attachedWorkspaceIds).toEqual(['ws-1', 'ws-2'])
|
||||
expect(result.addedMemberIds).toEqual(['member-1'])
|
||||
expect(result.skippedMembers).toEqual([])
|
||||
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith({
|
||||
userId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
role: 'owner',
|
||||
skipSeatValidation: true,
|
||||
})
|
||||
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith({
|
||||
userId: 'member-1',
|
||||
organizationId: 'org-1',
|
||||
role: 'member',
|
||||
skipSeatValidation: true,
|
||||
})
|
||||
expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('member-1')
|
||||
expect(mockReapplyPaidOrgJoinBillingForExistingMember).toHaveBeenCalledWith('owner-1', 'org-1')
|
||||
expect(mockReapplyPaidOrgJoinBillingForExistingMember).not.toHaveBeenCalledWith(
|
||||
'member-1',
|
||||
'org-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails before attaching workspaces when an existing member belongs to another organization', async () => {
|
||||
mockDbResults.value = [
|
||||
[{ id: 'ws-1' }],
|
||||
[{ userId: 'owner-1' }],
|
||||
[{ userId: 'owner-1' }, { userId: 'member-2' }],
|
||||
[{ userId: 'member-2', organizationId: 'org-2' }],
|
||||
]
|
||||
|
||||
await expect(
|
||||
attachOwnedWorkspacesToOrganization({
|
||||
ownerUserId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
).rejects.toBeInstanceOf(WorkspaceOrganizationMembershipConflictError)
|
||||
|
||||
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
|
||||
expect(mockDbUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps cross-org members external and still attaches when policy is keep-external', async () => {
|
||||
mockDbResults.value = [
|
||||
[{ id: 'ws-1' }],
|
||||
[{ userId: 'owner-1' }],
|
||||
[{ userId: 'owner-1' }, { userId: 'member-2' }],
|
||||
[{ userId: 'member-2', organizationId: 'org-2' }],
|
||||
]
|
||||
mockEnsureUserInOrganization.mockResolvedValueOnce({
|
||||
success: true,
|
||||
alreadyMember: true,
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await attachOwnedWorkspacesToOrganization({
|
||||
ownerUserId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
externalMemberPolicy: 'keep-external',
|
||||
})
|
||||
|
||||
expect(result.attachedWorkspaceIds).toEqual(['ws-1'])
|
||||
expect(result.skippedMembers).toEqual([
|
||||
{
|
||||
userId: 'member-2',
|
||||
reason: 'Already a member of another organization; kept as external workspace member',
|
||||
},
|
||||
])
|
||||
expect(mockEnsureUserInOrganization).toHaveBeenCalledTimes(1)
|
||||
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith({
|
||||
userId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
role: 'owner',
|
||||
skipSeatValidation: true,
|
||||
})
|
||||
expect(mockEnsureUserInOrganization).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'member-2' })
|
||||
)
|
||||
expect(mockDbUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detaches organization workspaces into grandfathered shared mode', async () => {
|
||||
mockDbResults.value = [[{ userId: 'owner-1' }], [{ id: 'ws-1', ownerId: 'creator-1' }]]
|
||||
|
||||
const result = await detachOrganizationWorkspaces('org-1')
|
||||
|
||||
expect(result.detachedWorkspaceIds).toEqual(['ws-1'])
|
||||
expect(result.billedAccountUserId).toBe('owner-1')
|
||||
expect(mockUpdateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organizationId: null,
|
||||
workspaceMode: 'grandfathered_shared',
|
||||
billedAccountUserId: 'owner-1',
|
||||
})
|
||||
)
|
||||
expect(mockOnConflictDoUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
ensureUserInOrganization,
|
||||
reapplyPaidOrgJoinBillingForExistingMember,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
import { getOrganizationOwnerId, WORKSPACE_MODE } from '@/lib/workspaces/policy'
|
||||
|
||||
const logger = createLogger('OrganizationWorkspaces')
|
||||
|
||||
export interface AttachOwnedWorkspacesToOrganizationResult {
|
||||
attachedWorkspaceIds: string[]
|
||||
addedMemberIds: string[]
|
||||
skippedMembers: Array<{ userId: string; reason: string }>
|
||||
}
|
||||
|
||||
export interface DetachOrganizationWorkspacesResult {
|
||||
detachedWorkspaceIds: string[]
|
||||
billedAccountUserId: string | null
|
||||
}
|
||||
|
||||
export class WorkspaceOrganizationMembershipConflictError extends Error {
|
||||
conflicts: Array<{ userId: string; organizationId: string }>
|
||||
|
||||
constructor(conflicts: Array<{ userId: string; organizationId: string }>) {
|
||||
super(
|
||||
'One or more workspace members already belong to another organization and cannot be attached.'
|
||||
)
|
||||
this.name = 'WorkspaceOrganizationMembershipConflictError'
|
||||
this.conflicts = conflicts
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How to treat workspace members that already belong to a *different*
|
||||
* organization when attaching workspaces:
|
||||
* - `reject` (default): throw a conflict — used by manual org creation.
|
||||
* - `keep-external`: skip them (they stay external workspace members) and
|
||||
* attach anyway — used by the Pro→Team conversion, which must not abort
|
||||
* just because a personal workspace already has an external collaborator.
|
||||
*/
|
||||
type ExternalMemberPolicy = 'reject' | 'keep-external'
|
||||
|
||||
interface AttachOwnedWorkspacesToOrganizationParams {
|
||||
ownerUserId: string
|
||||
organizationId: string
|
||||
externalMemberPolicy?: ExternalMemberPolicy
|
||||
}
|
||||
|
||||
export async function attachOwnedWorkspacesToOrganization({
|
||||
ownerUserId,
|
||||
organizationId,
|
||||
externalMemberPolicy = 'reject',
|
||||
}: AttachOwnedWorkspacesToOrganizationParams): Promise<AttachOwnedWorkspacesToOrganizationResult> {
|
||||
const ownedWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.ownerId, ownerUserId))
|
||||
|
||||
const billedAccountUserId = await getOrganizationOwnerId(organizationId)
|
||||
if (!billedAccountUserId) {
|
||||
logger.error('Attempted to attach workspaces to an organization without an owner', {
|
||||
organizationId,
|
||||
ownerUserId,
|
||||
})
|
||||
throw new Error(`Organization ${organizationId} has no owner membership`)
|
||||
}
|
||||
const ownedWorkspaceIds = ownedWorkspaces.map((ownedWorkspace) => ownedWorkspace.id)
|
||||
const uniqueWorkspaceMemberIds = await getWorkspaceMemberIds(ownedWorkspaceIds)
|
||||
const { joinableUserIds, externalConflicts } = await partitionWorkspaceMembersByOrg(
|
||||
uniqueWorkspaceMemberIds,
|
||||
organizationId
|
||||
)
|
||||
|
||||
if (externalMemberPolicy === 'reject' && externalConflicts.length > 0) {
|
||||
logger.warn('Workspace attachment blocked by members in another organization', {
|
||||
organizationId,
|
||||
conflictCount: externalConflicts.length,
|
||||
conflictingUserIds: externalConflicts.map((conflict) => conflict.userId),
|
||||
})
|
||||
throw new WorkspaceOrganizationMembershipConflictError(externalConflicts)
|
||||
}
|
||||
|
||||
const skippedMembers = externalConflicts.map((conflict) => ({
|
||||
userId: conflict.userId,
|
||||
reason: 'Already a member of another organization; kept as external workspace member',
|
||||
}))
|
||||
|
||||
const addedMemberIds: string[] = []
|
||||
|
||||
for (const userId of joinableUserIds) {
|
||||
const result = await ensureUserInOrganization({
|
||||
userId,
|
||||
organizationId,
|
||||
role: userId === billedAccountUserId ? 'owner' : 'member',
|
||||
skipSeatValidation: true,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Failed to sync workspace member into organization before attachment', {
|
||||
userId,
|
||||
organizationId,
|
||||
ownerUserId,
|
||||
error: result.error,
|
||||
})
|
||||
throw new Error(result.error || 'Failed to sync workspace member into organization')
|
||||
}
|
||||
|
||||
if (result.alreadyMember) {
|
||||
await reapplyPaidOrgJoinBillingForExistingMember(userId, organizationId)
|
||||
} else {
|
||||
addedMemberIds.push(userId)
|
||||
await syncUsageLimitsFromSubscription(userId)
|
||||
}
|
||||
}
|
||||
|
||||
const attachedWorkspaceIds = await db.transaction(async (tx) => {
|
||||
const touched: string[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (const ownedWorkspace of ownedWorkspaces) {
|
||||
await tx
|
||||
.update(workspace)
|
||||
.set({
|
||||
organizationId,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
billedAccountUserId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workspace.id, ownedWorkspace.id))
|
||||
|
||||
await tx
|
||||
.insert(permissions)
|
||||
.values({
|
||||
id: generateId(),
|
||||
userId: billedAccountUserId,
|
||||
entityType: 'workspace',
|
||||
entityId: ownedWorkspace.id,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [permissions.userId, permissions.entityType, permissions.entityId],
|
||||
set: {
|
||||
permissionType: 'admin',
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
touched.push(ownedWorkspace.id)
|
||||
}
|
||||
|
||||
return touched
|
||||
})
|
||||
|
||||
logger.info('Attached owned workspaces to organization', {
|
||||
ownerUserId,
|
||||
organizationId,
|
||||
attachedWorkspaceCount: attachedWorkspaceIds.length,
|
||||
addedMemberCount: addedMemberIds.length,
|
||||
skippedMemberCount: skippedMembers.length,
|
||||
})
|
||||
|
||||
return {
|
||||
attachedWorkspaceIds,
|
||||
addedMemberIds,
|
||||
skippedMembers,
|
||||
}
|
||||
}
|
||||
|
||||
export async function detachOrganizationWorkspaces(
|
||||
organizationId: string
|
||||
): Promise<DetachOrganizationWorkspacesResult> {
|
||||
const organizationOwnerId = await getOrganizationOwnerId(organizationId)
|
||||
if (!organizationOwnerId) {
|
||||
logger.warn(
|
||||
'Detaching workspaces from an organization without an owner; using workspace owner as billed account',
|
||||
{ organizationId }
|
||||
)
|
||||
}
|
||||
|
||||
const organizationWorkspaces = await db
|
||||
.select({ id: workspace.id, ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(
|
||||
and(
|
||||
eq(workspace.organizationId, organizationId),
|
||||
eq(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION)
|
||||
)
|
||||
)
|
||||
|
||||
const detachedWorkspaceIds = await db.transaction(async (tx) => {
|
||||
const touched: string[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (const organizationWorkspace of organizationWorkspaces) {
|
||||
const billedAccountUserId = organizationOwnerId ?? organizationWorkspace.ownerId
|
||||
|
||||
await tx
|
||||
.update(workspace)
|
||||
.set({
|
||||
organizationId: null,
|
||||
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
|
||||
billedAccountUserId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workspace.id, organizationWorkspace.id))
|
||||
|
||||
await tx
|
||||
.insert(permissions)
|
||||
.values({
|
||||
id: generateId(),
|
||||
userId: billedAccountUserId,
|
||||
entityType: 'workspace',
|
||||
entityId: organizationWorkspace.id,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [permissions.userId, permissions.entityType, permissions.entityId],
|
||||
set: {
|
||||
permissionType: 'admin',
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
touched.push(organizationWorkspace.id)
|
||||
}
|
||||
|
||||
return touched
|
||||
})
|
||||
|
||||
logger.info('Detached organization workspaces', {
|
||||
organizationId,
|
||||
detachedWorkspaceCount: detachedWorkspaceIds.length,
|
||||
billedAccountUserId: organizationOwnerId,
|
||||
})
|
||||
|
||||
return {
|
||||
detachedWorkspaceIds,
|
||||
billedAccountUserId: organizationOwnerId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split workspace members into those who can join the target organization
|
||||
* (members of no org, or already members of this org) and those who already
|
||||
* belong to a different org (external conflicts). Single-org membership means
|
||||
* each user has at most one membership row, so the split is unambiguous.
|
||||
*/
|
||||
async function partitionWorkspaceMembersByOrg(
|
||||
userIds: string[],
|
||||
organizationId: string
|
||||
): Promise<{
|
||||
joinableUserIds: string[]
|
||||
externalConflicts: Array<{ userId: string; organizationId: string }>
|
||||
}> {
|
||||
if (userIds.length === 0) {
|
||||
return { joinableUserIds: [], externalConflicts: [] }
|
||||
}
|
||||
|
||||
const memberships = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
})
|
||||
.from(member)
|
||||
.where(inArray(member.userId, userIds))
|
||||
|
||||
const externalConflicts = memberships.filter(
|
||||
(membership) => membership.organizationId !== organizationId
|
||||
)
|
||||
const externalUserIds = new Set(externalConflicts.map((conflict) => conflict.userId))
|
||||
const joinableUserIds = userIds.filter((userId) => !externalUserIds.has(userId))
|
||||
|
||||
return { joinableUserIds, externalConflicts }
|
||||
}
|
||||
|
||||
async function getWorkspaceMemberIds(workspaceIds: string[]): Promise<string[]> {
|
||||
if (workspaceIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(eq(permissions.entityType, 'workspace'), inArray(permissions.entityId, workspaceIds))
|
||||
)
|
||||
|
||||
return [...new Set(rows.map((row) => row.userId))]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Export types
|
||||
export type { Member } from '@/lib/workspaces/organization/types'
|
||||
// Export utility functions
|
||||
export {
|
||||
generateSlug,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
} from '@/lib/workspaces/organization/utils'
|
||||
@@ -0,0 +1,163 @@
|
||||
interface User {
|
||||
name?: string
|
||||
email?: string
|
||||
id?: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
role: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string
|
||||
email: string
|
||||
status: string
|
||||
membershipIntent?: 'internal' | 'external'
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo?: string | null
|
||||
members?: Member[]
|
||||
invitations?: Invitation[]
|
||||
createdAt: string | Date
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
plan: string
|
||||
status: string
|
||||
seats?: number
|
||||
referenceId: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
periodEnd?: number | Date
|
||||
trialEnd?: number | Date
|
||||
metadata?: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
permission: string
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
isOwner: boolean
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
interface OrganizationFormData {
|
||||
name: string
|
||||
slug: string
|
||||
logo: string
|
||||
}
|
||||
|
||||
interface MemberUsageData {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
percentUsed: number
|
||||
isOverLimit: boolean
|
||||
role: string
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
interface OrganizationBillingData {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
subscriptionPlan: string
|
||||
subscriptionStatus: string
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
seatsCount: number
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
minimumBillingAmount: number
|
||||
averageUsagePerMember: number
|
||||
billingPeriodStart: string | null
|
||||
billingPeriodEnd: string | null
|
||||
members?: MemberUsageData[]
|
||||
userRole?: string
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
interface OrganizationState {
|
||||
// Core organization data
|
||||
organizations: Organization[]
|
||||
activeOrganization: Organization | null
|
||||
|
||||
// Team management
|
||||
subscriptionData: Subscription | null
|
||||
userWorkspaces: Workspace[]
|
||||
|
||||
// Organization billing and usage
|
||||
organizationBillingData: OrganizationBillingData | null
|
||||
|
||||
// Organization settings
|
||||
orgFormData: OrganizationFormData
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean
|
||||
isLoadingSubscription: boolean
|
||||
isLoadingOrgBilling: boolean
|
||||
isCreatingOrg: boolean
|
||||
isInviting: boolean
|
||||
isSavingOrgSettings: boolean
|
||||
|
||||
// Error states
|
||||
error: string | null
|
||||
orgSettingsError: string | null
|
||||
|
||||
// Success states
|
||||
inviteSuccess: boolean
|
||||
orgSettingsSuccess: string | null
|
||||
|
||||
// Cache timestamps
|
||||
lastFetched: number | null
|
||||
lastSubscriptionFetched: number | null
|
||||
lastOrgBillingFetched: number | null
|
||||
|
||||
// User permissions
|
||||
hasTeamPlan: boolean
|
||||
hasEnterprisePlan: boolean
|
||||
}
|
||||
|
||||
interface OrganizationStore extends OrganizationState {
|
||||
loadData: () => Promise<void>
|
||||
loadOrganizationSubscription: (orgId: string) => Promise<void>
|
||||
loadOrganizationBillingData: (organizationId: string, force?: boolean) => Promise<void>
|
||||
loadUserWorkspaces: (userId?: string) => Promise<void>
|
||||
refreshOrganization: () => Promise<void>
|
||||
|
||||
// Organization management
|
||||
createOrganization: (name: string, slug: string) => Promise<void>
|
||||
setActiveOrganization: (orgId: string) => Promise<void>
|
||||
updateOrganizationSettings: () => Promise<void>
|
||||
|
||||
// Team management
|
||||
inviteMember: (email: string, workspaceInvitations?: WorkspaceInvitation[]) => Promise<void>
|
||||
removeMember: (memberId: string) => Promise<void>
|
||||
cancelInvitation: (invitationId: string) => Promise<void>
|
||||
|
||||
transferSubscriptionToOrganization: (orgId: string) => Promise<void>
|
||||
|
||||
getUserRole: (userEmail?: string) => string
|
||||
isAdminOrOwner: (userEmail?: string) => boolean
|
||||
getUsedSeats: () => { used: number; members: number; pending: number }
|
||||
|
||||
setOrgFormData: (data: Partial<OrganizationFormData>) => void
|
||||
|
||||
clearError: () => void
|
||||
clearSuccessMessages: () => void
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateSeatUsage } from '@/lib/workspaces/organization/utils'
|
||||
|
||||
describe('calculateSeatUsage', () => {
|
||||
it('does not count external pending workspace invitations as occupied seats', () => {
|
||||
const seats = calculateSeatUsage({
|
||||
id: 'org-1',
|
||||
name: 'Acme',
|
||||
slug: 'acme',
|
||||
createdAt: new Date(),
|
||||
members: [
|
||||
{ id: 'member-1', role: 'owner' },
|
||||
{ id: 'member-2', role: 'member' },
|
||||
],
|
||||
invitations: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
email: 'internal@example.com',
|
||||
status: 'pending',
|
||||
membershipIntent: 'internal',
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
email: 'external@example.com',
|
||||
status: 'pending',
|
||||
membershipIntent: 'external',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(seats).toEqual({ used: 3, members: 2, pending: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Utility functions for organization-related operations
|
||||
* These are pure functions that compute values from organization data
|
||||
*/
|
||||
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import type { Organization } from '@/lib/workspaces/organization/types'
|
||||
|
||||
/**
|
||||
* Get the role of a user in an organization
|
||||
*/
|
||||
export function getUserRole(
|
||||
organization: Organization | null | undefined,
|
||||
userEmail?: string
|
||||
): string {
|
||||
if (!userEmail || !organization?.members) {
|
||||
return 'member'
|
||||
}
|
||||
const currentMember = organization.members.find((m) => m.user?.email === userEmail)
|
||||
return currentMember?.role ?? 'member'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an admin or owner in an organization
|
||||
*/
|
||||
export function isAdminOrOwner(
|
||||
organization: Organization | null | undefined,
|
||||
userEmail?: string
|
||||
): boolean {
|
||||
const role = getUserRole(organization, userEmail)
|
||||
return isOrgAdminRole(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate seat usage for an organization
|
||||
*/
|
||||
export function calculateSeatUsage(organization: Organization | null | undefined): {
|
||||
used: number
|
||||
members: number
|
||||
pending: number
|
||||
} {
|
||||
if (!organization) {
|
||||
return { used: 0, members: 0, pending: 0 }
|
||||
}
|
||||
|
||||
const membersCount = organization.members?.length || 0
|
||||
const pendingInvitationsCount =
|
||||
organization.invitations?.filter(
|
||||
(inv) => inv.status === 'pending' && inv.membershipIntent !== 'external'
|
||||
).length || 0
|
||||
|
||||
return {
|
||||
used: membersCount + pendingInvitationsCount,
|
||||
members: membersCount,
|
||||
pending: pendingInvitationsCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get used seats from an organization
|
||||
* Alias for calculateSeatUsage
|
||||
*/
|
||||
export function getUsedSeats(organization: Organization | null | undefined) {
|
||||
return calculateSeatUsage(organization)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-friendly slug from a name
|
||||
*/
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
|
||||
.replace(/-+/g, '-') // Replace consecutive hyphens with single hyphen
|
||||
.replace(/^-|-$/g, '') // Remove leading and trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate organization slug format
|
||||
*/
|
||||
export function validateSlug(slug: string): boolean {
|
||||
const slugRegex = /^[a-z0-9-_]+$/
|
||||
return slugRegex.test(slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function validateEmail(email: string): boolean {
|
||||
return quickValidateEmail(normalizeEmail(email)).isValid
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
import { db } from '@sim/db'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkWorkspaceAccess,
|
||||
getManageableWorkspaces,
|
||||
getUserEntityPermissions,
|
||||
getUsersWithPermissions,
|
||||
getWorkspaceById,
|
||||
getWorkspaceWithOwner,
|
||||
hasWorkspaceAdminAccess,
|
||||
workspaceExists,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const mockDb = db as any
|
||||
type PermissionType = 'admin' | 'write' | 'read'
|
||||
|
||||
function createMockChain(finalResult: any) {
|
||||
const chain: any = {}
|
||||
|
||||
chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult))
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.leftJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
describe('Permission Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getUserEntityPermissions', () => {
|
||||
it('should return null when user has no permissions', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return the highest permission when user has multiple permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should return single permission when user has only one', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should prioritize admin over other permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user999', 'workflow', 'workflow999')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should return write permission when user only has write access', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it('should prioritize write over read permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it('should work with workflow entity type', async () => {
|
||||
const mockResults = [{ permissionType: 'admin' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should work with organization entity type', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'organization', 'org456')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should handle generic entity types', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'custom_entity', 'entity123')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersWithPermissions', () => {
|
||||
function mockSelectSequence(results: any[][]) {
|
||||
let index = 0
|
||||
mockDb.select.mockImplementation(() => createMockChain(results[index++] ?? []))
|
||||
}
|
||||
|
||||
const joinedAt = new Date('2026-04-22T00:00:00.000Z')
|
||||
|
||||
it('should return empty array when the workspace does not exist', async () => {
|
||||
mockSelectSequence([[]])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return users with their explicit permissions for a personal workspace', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'workspace456', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Smith',
|
||||
image: 'https://example.com/alice.png',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Smith',
|
||||
image: 'https://example.com/alice.png',
|
||||
permissionType: 'admin',
|
||||
isExternal: false,
|
||||
joinedAt: '2026-04-22T00:00:00.000Z',
|
||||
roleSource: 'explicit',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('tags the workspace owner with roleSource owner', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'workspace456', ownerId: 'user1', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result[0].roleSource).toBe('owner')
|
||||
})
|
||||
|
||||
it('merges organization admins as derived workspace admins', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: 'org-1' }],
|
||||
[
|
||||
{
|
||||
userId: 'member-user',
|
||||
email: 'member@example.com',
|
||||
name: 'Member',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
userId: 'org-admin-user',
|
||||
email: 'orgadmin@example.com',
|
||||
name: 'Org Admin',
|
||||
image: null,
|
||||
joinedAt,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('ws')
|
||||
const orgAdmin = result.find((u) => u.userId === 'org-admin-user')
|
||||
|
||||
expect(orgAdmin).toMatchObject({
|
||||
permissionType: 'admin',
|
||||
roleSource: 'org-admin',
|
||||
isExternal: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks users as external when they are not members of the workspace organization', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'internal-user', organizationId: 'org-1' }],
|
||||
[
|
||||
{
|
||||
userId: 'internal-user',
|
||||
email: 'internal@example.com',
|
||||
name: 'Internal User',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-1',
|
||||
},
|
||||
{
|
||||
userId: 'external-user',
|
||||
email: 'external@example.com',
|
||||
name: 'External User',
|
||||
image: null,
|
||||
permissionType: 'write' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-2',
|
||||
},
|
||||
],
|
||||
[],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('ws')
|
||||
const byEmail = new Map(result.map((u) => [u.email, u.isExternal]))
|
||||
|
||||
expect(byEmail.get('internal@example.com')).toBe(false)
|
||||
expect(byEmail.get('external@example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('marks a non-owner member of another org as external on a personal workspace', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'owner-user',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
{
|
||||
userId: 'guest-user',
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest',
|
||||
image: null,
|
||||
permissionType: 'write' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-guest',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace-personal')
|
||||
const byEmail = new Map(result.map((u) => [u.email, u.isExternal]))
|
||||
|
||||
expect(byEmail.get('owner@example.com')).toBe(false)
|
||||
expect(byEmail.get('guest@example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return multiple users sorted by email', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'a-admin@example.com',
|
||||
name: 'Admin User',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
{
|
||||
userId: 'user2',
|
||||
email: 'b-writer@example.com',
|
||||
name: 'Writer User',
|
||||
image: null,
|
||||
permissionType: 'write' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
{
|
||||
userId: 'user3',
|
||||
email: 'c-reader@example.com',
|
||||
name: 'Reader User',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].permissionType).toBe('admin')
|
||||
expect(result[1].permissionType).toBe('write')
|
||||
expect(result[2].permissionType).toBe('read')
|
||||
})
|
||||
|
||||
it('should handle users with empty names', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'test@example.com',
|
||||
name: '',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
|
||||
expect(result[0].name).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasWorkspaceAdminAccess', () => {
|
||||
it('should return true for the workspace owner via their explicit admin row', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'user123' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when user has direct admin permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has no admin access', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has write permission but not admin', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has read permission but not admin', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', '')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Security Tests', () => {
|
||||
it('should handle SQL injection attempts in user IDs', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions(
|
||||
"'; DROP TABLE users; --",
|
||||
'workspace',
|
||||
'workspace123'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle very long entity IDs', async () => {
|
||||
const longEntityId = 'a'.repeat(1000)
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', longEntityId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle unicode characters in entity names', async () => {
|
||||
const chain = createMockChain([{ permissionType: 'read' as PermissionType }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', '📝workspace', '🏢org-id')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should verify permission hierarchy ordering is consistent', () => {
|
||||
const permissionOrder: Record<PermissionType, number> = { admin: 3, write: 2, read: 1 }
|
||||
|
||||
expect(permissionOrder.admin).toBeGreaterThan(permissionOrder.write)
|
||||
expect(permissionOrder.write).toBeGreaterThan(permissionOrder.read)
|
||||
})
|
||||
|
||||
it('should handle workspace ownership checks with null owner IDs', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: null }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle null user ID correctly when owner ID is different', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess(null as any, 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getManageableWorkspaces', () => {
|
||||
it('should return empty array when user has no manageable workspaces', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return owned workspaces', async () => {
|
||||
const mockWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123' },
|
||||
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockWorkspaces) // Owned workspaces
|
||||
}
|
||||
return createMockChain([]) // No admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123', accessType: 'owner' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should return workspaces with direct admin permissions', async () => {
|
||||
const mockAdminWorkspaces = [{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([]) // No owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should combine owned and admin workspaces without duplicates', async () => {
|
||||
const mockOwnedWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' },
|
||||
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123' },
|
||||
]
|
||||
const mockAdminWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' }, // Duplicate (should be filtered)
|
||||
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockOwnedWorkspaces) // Owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty workspace names', async () => {
|
||||
const mockWorkspaces = [{ id: 'ws1', name: '', ownerId: 'user123' }]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockWorkspaces)
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result[0].name).toBe('')
|
||||
})
|
||||
|
||||
it('should handle multiple admin permissions for same workspace', async () => {
|
||||
const mockAdminWorkspaces = [
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' },
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }, // Duplicate
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([]) // No owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces with duplicates
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle empty user ID gracefully', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getManageableWorkspaces('')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceById', () => {
|
||||
it.concurrent('should return workspace when it exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123' })
|
||||
})
|
||||
|
||||
it.concurrent('should return null when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceWithOwner', () => {
|
||||
it.concurrent('should return workspace with owner when it exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: 'owner456' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123', ownerId: 'owner456' })
|
||||
})
|
||||
|
||||
it.concurrent('should return null when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle workspace with null owner ID', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: null }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123', ownerId: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceExists', () => {
|
||||
it.concurrent('should return true when workspace exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('workspace123')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('non-existent')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkWorkspaceAccess', () => {
|
||||
it('should return exists=false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('non-existent', 'user123')
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: false,
|
||||
hasAccess: false,
|
||||
canWrite: false,
|
||||
canAdmin: false,
|
||||
workspace: null,
|
||||
permission: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return full access for the workspace owner via their explicit admin row', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'user123' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: true,
|
||||
hasAccess: true,
|
||||
canWrite: true,
|
||||
canAdmin: true,
|
||||
workspace: { id: 'workspace123', ownerId: 'user123' },
|
||||
permission: 'admin',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return hasAccess=false when user has no permissions', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([]) // No permissions
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect(result.canWrite).toBe(false)
|
||||
})
|
||||
|
||||
it('should return canWrite=true when user has admin permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
})
|
||||
|
||||
it('should return canWrite=true when user has write permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'write' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
})
|
||||
|
||||
it('should return canWrite=false when user has read permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'read' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', '')
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('', 'user123')
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('organization admin inheritance', () => {
|
||||
function mockSelectSequence(results: any[][]) {
|
||||
let index = 0
|
||||
mockDb.select.mockImplementation(() => createMockChain(results[index++] ?? []))
|
||||
}
|
||||
|
||||
it('checkWorkspaceAccess grants admin to org admins without an explicit row', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'admin' }],
|
||||
])
|
||||
|
||||
const result = await checkWorkspaceAccess('ws', 'org-admin-user')
|
||||
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
expect(result.canAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('getUserEntityPermissions returns admin for an org owner without an explicit row', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'owner' }],
|
||||
])
|
||||
|
||||
const result = await getUserEntityPermissions('org-owner-user', 'workspace', 'ws')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('hasWorkspaceAdminAccess is true for an org admin of the workspace org', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'admin' }],
|
||||
])
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('org-admin-user', 'ws')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('does not elevate a plain org member', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'member' }],
|
||||
])
|
||||
|
||||
const result = await checkWorkspaceAccess('ws', 'org-member-user')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect(result.canAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('does not elevate org admins on a workspace with no organization', async () => {
|
||||
mockSelectSequence([[{ id: 'ws', ownerId: 'other-user', organizationId: null }], []])
|
||||
|
||||
const result = await checkWorkspaceAccess('ws', 'some-user')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, user, type WorkspaceMode, workspace } from '@sim/db/schema'
|
||||
import {
|
||||
isOrgAdminRole,
|
||||
ORG_ADMIN_ROLES,
|
||||
PERMISSION_RANK,
|
||||
type PermissionType,
|
||||
permissionSatisfies,
|
||||
resolveEffectiveWorkspacePermission,
|
||||
} from '@sim/platform-authz/workspace'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { getOrgAdminWorkspaceRows } from '@/lib/workspaces/utils'
|
||||
|
||||
export type { PermissionType }
|
||||
export interface WorkspaceBasic {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WorkspaceWithOwner {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
organizationId: string | null
|
||||
workspaceMode: WorkspaceMode
|
||||
billedAccountUserId: string
|
||||
archivedAt?: Date | null
|
||||
}
|
||||
|
||||
export interface WorkspaceAccess {
|
||||
exists: boolean
|
||||
hasAccess: boolean
|
||||
canWrite: boolean
|
||||
canAdmin: boolean
|
||||
workspace: WorkspaceWithOwner | null
|
||||
/** The viewer's raw effective permission, or `null` when the workspace doesn't exist or they have none. */
|
||||
permission: PermissionType | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workspace exists
|
||||
*
|
||||
* @param workspaceId - The workspace ID to check
|
||||
* @returns True if the workspace exists, false otherwise
|
||||
*/
|
||||
export async function workspaceExists(
|
||||
workspaceId: string,
|
||||
options?: { includeArchived?: boolean }
|
||||
): Promise<boolean> {
|
||||
const { includeArchived = false } = options ?? {}
|
||||
const [ws] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(
|
||||
includeArchived
|
||||
? eq(workspace.id, workspaceId)
|
||||
: and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return !!ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace by ID for existence check
|
||||
*
|
||||
* @param workspaceId - The workspace ID to look up
|
||||
* @returns The workspace if found, null otherwise
|
||||
*/
|
||||
export async function getWorkspaceById(
|
||||
workspaceId: string,
|
||||
options?: { includeArchived?: boolean }
|
||||
): Promise<WorkspaceBasic | null> {
|
||||
const exists = await workspaceExists(workspaceId, options)
|
||||
return exists ? { id: workspaceId } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace with owner info by ID
|
||||
*
|
||||
* @param workspaceId - The workspace ID to look up
|
||||
* @returns The workspace with owner info if found, null otherwise
|
||||
*/
|
||||
export async function getWorkspaceWithOwner(
|
||||
workspaceId: string,
|
||||
options?: { includeArchived?: boolean }
|
||||
): Promise<WorkspaceWithOwner | null> {
|
||||
const { includeArchived = false } = options ?? {}
|
||||
const [ws] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceMode: workspace.workspaceMode,
|
||||
billedAccountUserId: workspace.billedAccountUserId,
|
||||
archivedAt: workspace.archivedAt,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(
|
||||
includeArchived
|
||||
? eq(workspace.id, workspaceId)
|
||||
: and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return ws || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective workspace permission for a user under the governance
|
||||
* inheritance model: the owners/admins of the organization that owns the
|
||||
* workspace are derived workspace admins. Returns the higher of any explicit
|
||||
* grant and the org-admin derivation. The workspace owner is not special-cased —
|
||||
* they always hold an explicit `admin` row, so the resolver's lookup covers them.
|
||||
*
|
||||
* Delegates to the shared resolver in `@sim/platform-authz/workspace` so the
|
||||
* rule has a single source of truth shared with the realtime server.
|
||||
*
|
||||
* @param userId - The user to resolve the permission for
|
||||
* @param ws - The workspace (organization already loaded)
|
||||
*/
|
||||
export async function getEffectiveWorkspacePermission(
|
||||
userId: string,
|
||||
ws: Pick<WorkspaceWithOwner, 'id' | 'organizationId'>
|
||||
): Promise<PermissionType | null> {
|
||||
return resolveEffectiveWorkspacePermission(userId, ws.id, ws.organizationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check workspace access for a user
|
||||
*
|
||||
* Verifies the workspace exists and the user has access to it.
|
||||
* Returns access level (read/write) based on ownership, explicit permissions,
|
||||
* and organization-admin inheritance.
|
||||
*
|
||||
* @param workspaceId - The workspace ID to check
|
||||
* @param userId - The user ID to check access for
|
||||
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
|
||||
*/
|
||||
export async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccess> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
|
||||
if (!ws) {
|
||||
return {
|
||||
exists: false,
|
||||
hasAccess: false,
|
||||
canWrite: false,
|
||||
canAdmin: false,
|
||||
workspace: null,
|
||||
permission: null,
|
||||
}
|
||||
}
|
||||
|
||||
const permission = await getEffectiveWorkspacePermission(userId, ws)
|
||||
const hasAccess = permission !== null
|
||||
const canWrite = permissionSatisfies(permission, 'write')
|
||||
const canAdmin = permissionSatisfies(permission, 'admin')
|
||||
|
||||
return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission }
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a user attempts to access a workspace they don't have access to,
|
||||
* or that doesn't exist / has been archived. Carries `statusCode = 403` so the
|
||||
* centralized route wrapper maps it to HTTP 403 instead of defaulting to 500.
|
||||
* The `message` is intentionally client-safe and is exposed to API responses.
|
||||
*/
|
||||
export class WorkspaceAccessDeniedError extends HttpError {
|
||||
readonly statusCode = 403
|
||||
readonly workspaceId: string
|
||||
|
||||
constructor(workspaceId: string) {
|
||||
super(`Workspace access denied: ${workspaceId}`)
|
||||
this.name = 'WorkspaceAccessDeniedError'
|
||||
this.workspaceId = workspaceId
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceAccessDeniedError(error: unknown): error is WorkspaceAccessDeniedError {
|
||||
return error instanceof WorkspaceAccessDeniedError
|
||||
}
|
||||
|
||||
export async function assertActiveWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccess> {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.exists || !access.hasAccess) {
|
||||
throw new WorkspaceAccessDeniedError(workspaceId)
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest permission level a user has for a specific entity
|
||||
*
|
||||
* @param userId - The ID of the user to check permissions for
|
||||
* @param entityType - The type of entity (e.g., 'workspace', 'workflow', etc.)
|
||||
* @param entityId - The ID of the specific entity
|
||||
* @returns Promise<PermissionType | null> - The highest permission the user has for the entity, or null if none
|
||||
*/
|
||||
export async function getUserEntityPermissions(
|
||||
userId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<PermissionType | null> {
|
||||
if (entityType === 'workspace') {
|
||||
return (await checkWorkspaceAccess(entityId, userId)).permission
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, entityType),
|
||||
eq(permissions.entityId, entityId)
|
||||
)
|
||||
)
|
||||
|
||||
if (result.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const highestPermission = result.reduce((highest, current) => {
|
||||
return PERMISSION_RANK[current.permissionType] > PERMISSION_RANK[highest.permissionType]
|
||||
? current
|
||||
: highest
|
||||
})
|
||||
|
||||
return highestPermission.permissionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of users with their associated permissions for a given workspace.
|
||||
*
|
||||
* A member is `isExternal` when they hold workspace access but belong to a
|
||||
* different organization than the workspace (or to any organization when the
|
||||
* workspace is personal/grandfathered and has none). The workspace owner is
|
||||
* never external. This mirrors the accept-time `external` membership intent so
|
||||
* the UI tag matches how the member actually joined.
|
||||
*
|
||||
* @param workspaceId - The ID of the workspace to retrieve user permissions for.
|
||||
* @returns A promise that resolves to an array of user objects, each containing user details and their permission type.
|
||||
*/
|
||||
export type MemberRoleSource = 'owner' | 'explicit' | 'org-admin'
|
||||
|
||||
export interface WorkspaceMemberWithRole {
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
image: string | null
|
||||
permissionType: PermissionType
|
||||
isExternal: boolean
|
||||
joinedAt: string
|
||||
/**
|
||||
* Where the effective role comes from. `org-admin` and `owner` roles are
|
||||
* derived and cannot be changed through the member UI.
|
||||
*/
|
||||
roleSource: MemberRoleSource
|
||||
}
|
||||
|
||||
export async function getUsersWithPermissions(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMemberWithRole[]> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
if (!ws) return []
|
||||
|
||||
const explicitRows = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
permissionType: permissions.permissionType,
|
||||
joinedAt: permissions.createdAt,
|
||||
userOrganizationId: member.organizationId,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.leftJoin(member, eq(member.userId, user.id))
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
|
||||
|
||||
const byUser = new Map<string, WorkspaceMemberWithRole>()
|
||||
|
||||
for (const row of explicitRows) {
|
||||
const isOwner = row.userId === ws.ownerId
|
||||
byUser.set(row.userId, {
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
image: row.image ?? null,
|
||||
permissionType: row.permissionType,
|
||||
isExternal: !isOwner && row.userOrganizationId !== ws.organizationId,
|
||||
joinedAt: row.joinedAt.toISOString(),
|
||||
roleSource: isOwner ? 'owner' : 'explicit',
|
||||
})
|
||||
}
|
||||
|
||||
if (ws.organizationId) {
|
||||
const orgAdmins = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
joinedAt: member.createdAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, ws.organizationId),
|
||||
inArray(member.role, [...ORG_ADMIN_ROLES])
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of orgAdmins) {
|
||||
const isOwner = row.userId === ws.ownerId
|
||||
const existing = byUser.get(row.userId)
|
||||
if (existing) {
|
||||
existing.permissionType = 'admin'
|
||||
existing.isExternal = false
|
||||
if (existing.roleSource !== 'owner') {
|
||||
existing.roleSource = isOwner ? 'owner' : 'org-admin'
|
||||
}
|
||||
} else {
|
||||
byUser.set(row.userId, {
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
image: row.image ?? null,
|
||||
permissionType: 'admin',
|
||||
isExternal: false,
|
||||
joinedAt: row.joinedAt.toISOString(),
|
||||
roleSource: isOwner ? 'owner' : 'org-admin',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byUser.values()).sort((a, b) => a.email.localeCompare(b.email))
|
||||
}
|
||||
|
||||
/** Lightweight profile data for workspace member display (avatars, owner cells). */
|
||||
export interface WorkspaceMemberProfile {
|
||||
userId: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches minimal profile data (id, name, image) for all members of a workspace.
|
||||
* Use this instead of getUsersWithPermissions when you only need display info.
|
||||
*/
|
||||
export async function getWorkspaceMemberProfiles(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMemberProfile[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
isNull(workspace.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export interface WorkspacePermissionsForViewer {
|
||||
users: WorkspaceMemberWithRole[]
|
||||
total: number
|
||||
viewer: {
|
||||
userId: string
|
||||
isAdmin: boolean
|
||||
permissionType: PermissionType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the workspace permissions payload for a viewer: the full member list plus
|
||||
* the viewer's own resolved permission. Shared by `GET /api/workspaces/[id]/permissions`
|
||||
* and the sidebar prefetch so the two never drift.
|
||||
*
|
||||
* @param workspaceId - The workspace ID to build permissions for
|
||||
* @param userId - The viewer's user ID
|
||||
* @returns The permissions payload, or `null` if the workspace doesn't exist or the viewer lacks access
|
||||
*/
|
||||
export async function getWorkspacePermissionsForViewer(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspacePermissionsForViewer | null> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
if (!ws) return null
|
||||
|
||||
const permission = await getEffectiveWorkspacePermission(userId, ws)
|
||||
if (permission === null) return null
|
||||
|
||||
const users = await getUsersWithPermissions(workspaceId)
|
||||
|
||||
return {
|
||||
users,
|
||||
total: users.length,
|
||||
viewer: { userId, isAdmin: permission === 'admin', permissionType: permission },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has admin access to a specific workspace
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param workspaceId - The ID of the workspace to check
|
||||
* @returns Promise<boolean> - True if the user has admin access to the workspace, false otherwise
|
||||
*/
|
||||
export async function hasWorkspaceAdminAccess(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
return (await checkWorkspaceAccess(workspaceId, userId)).canAdmin
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user is an owner or admin of a specific organization.
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization to check
|
||||
* @returns Promise<boolean> - True when the user is the organization owner or an admin
|
||||
*/
|
||||
export async function isOrganizationAdminOrOwner(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
return isOrgAdminRole(row?.role)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user is a member (any role) of a specific organization.
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization to check
|
||||
* @returns Promise<boolean> - True when the user has an organization membership row
|
||||
*/
|
||||
export async function isOrganizationMember(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
return !!row
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of workspaces that the user has access to
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @returns Promise<Array<{
|
||||
* id: string
|
||||
* name: string
|
||||
* ownerId: string
|
||||
* accessType: 'direct' | 'owner'
|
||||
* }>> - A list of workspaces that the user has access to
|
||||
*/
|
||||
export async function getManageableWorkspaces(userId: string): Promise<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'owner'
|
||||
}>
|
||||
> {
|
||||
const ownedWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
|
||||
|
||||
const adminWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.innerJoin(permissions, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(workspace.archivedAt),
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
|
||||
const orgAdminWorkspaces = (await getOrgAdminWorkspaceRows(userId, 'active')).map((ws) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
ownerId: ws.ownerId,
|
||||
}))
|
||||
|
||||
const ownedSet = new Set(ownedWorkspaces.map((w) => w.id))
|
||||
const seen = new Set(ownedSet)
|
||||
const combined: Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'owner'
|
||||
}> = ownedWorkspaces.map((ws) => ({ ...ws, accessType: 'owner' as const }))
|
||||
|
||||
for (const ws of [...adminWorkspaces, ...orgAdminWorkspaces]) {
|
||||
if (seen.has(ws.id)) continue
|
||||
seen.add(ws.id)
|
||||
combined.push({ ...ws, accessType: 'direct' as const })
|
||||
}
|
||||
|
||||
return combined
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Shared constants for workspace policy messaging. Separated from
|
||||
* `policy.ts` so client components can import them without pulling in
|
||||
* server-only dependencies (`@sim/db`, drizzle helpers, etc.).
|
||||
*/
|
||||
|
||||
export const UPGRADE_TO_INVITE_REASON = 'Upgrade to invite more members'
|
||||
export const CONTACT_OWNER_TO_UPGRADE_REASON = 'Contact workspace owner to upgrade'
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetUserOrganization,
|
||||
mockGetOrganizationSubscription,
|
||||
mockGetHighestPrioritySubscription,
|
||||
mockDbResults,
|
||||
mockFeatureFlags,
|
||||
} = vi.hoisted(() => {
|
||||
const mockGetUserOrganization = vi.fn()
|
||||
const mockGetOrganizationSubscription = vi.fn()
|
||||
const mockGetHighestPrioritySubscription = vi.fn()
|
||||
const mockDbResults: { value: any[] } = { value: [] }
|
||||
const mockFeatureFlags = { isBillingEnabled: true }
|
||||
|
||||
return {
|
||||
mockGetUserOrganization,
|
||||
mockGetOrganizationSubscription,
|
||||
mockGetHighestPrioritySubscription,
|
||||
mockDbResults,
|
||||
mockFeatureFlags,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
const chain: any = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || []))
|
||||
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
|
||||
const result = mockDbResults.value.shift() || []
|
||||
return Promise.resolve(callback ? callback(result) : result)
|
||||
})
|
||||
return chain
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => schemaMock)
|
||||
|
||||
vi.mock('@/lib/billing/organizations/membership', () => ({
|
||||
getUserOrganization: mockGetUserOrganization,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/billing', () => ({
|
||||
getOrganizationSubscription: mockGetOrganizationSubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/plan', () => ({
|
||||
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
get isBillingEnabled() {
|
||||
return mockFeatureFlags.isBillingEnabled
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
getWorkspaceCreationPolicy,
|
||||
getWorkspaceInvitePolicy,
|
||||
WORKSPACE_MODE,
|
||||
} from '@/lib/workspaces/policy'
|
||||
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
|
||||
|
||||
describe('getWorkspaceCreationPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbResults.value = []
|
||||
mockFeatureFlags.isBillingEnabled = true
|
||||
mockGetUserOrganization.mockResolvedValue(null)
|
||||
mockGetOrganizationSubscription.mockResolvedValue(null)
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('blocks free users once they already own one non-organization workspace', async () => {
|
||||
mockDbResults.value = [[{ value: 1 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
|
||||
|
||||
expect(result.canCreate).toBe(false)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
|
||||
expect(result.maxWorkspaces).toBe(1)
|
||||
expect(result.currentWorkspaceCount).toBe(1)
|
||||
})
|
||||
|
||||
it('allows pro users to create up to three personal workspaces', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'pro_6000',
|
||||
status: 'active',
|
||||
})
|
||||
mockDbResults.value = [[{ value: 2 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
|
||||
expect(result.maxWorkspaces).toBe(3)
|
||||
expect(result.currentWorkspaceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('allows max users to create up to ten personal workspaces', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'pro_25000',
|
||||
status: 'active',
|
||||
})
|
||||
mockDbResults.value = [[{ value: 5 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
|
||||
expect(result.maxWorkspaces).toBe(10)
|
||||
expect(result.currentWorkspaceCount).toBe(5)
|
||||
})
|
||||
|
||||
it('blocks max users once they already own ten personal workspaces', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'pro_25000',
|
||||
status: 'active',
|
||||
})
|
||||
mockDbResults.value = [[{ value: 10 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
|
||||
|
||||
expect(result.canCreate).toBe(false)
|
||||
expect(result.maxWorkspaces).toBe(10)
|
||||
expect(result.currentWorkspaceCount).toBe(10)
|
||||
})
|
||||
|
||||
it('allows unlimited personal workspaces when billing is disabled', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
mockDbResults.value = [[{ value: 9 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
|
||||
expect(result.maxWorkspaces).toBeNull()
|
||||
expect(result.currentWorkspaceCount).toBe(9)
|
||||
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('without pinning, a null active org falls back to the caller membership org', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
mockGetUserOrganization.mockResolvedValue({
|
||||
organizationId: 'user-org',
|
||||
role: 'admin',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockDbResults.value = [[{ userId: 'owner-1' }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: null,
|
||||
})
|
||||
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
|
||||
expect(result.organizationId).toBe('user-org')
|
||||
})
|
||||
|
||||
it('pins to the source org: a personal source (null) stays personal regardless of caller org', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
mockGetUserOrganization.mockResolvedValue({
|
||||
organizationId: 'user-org',
|
||||
role: 'admin',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockDbResults.value = [[{ value: 0 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: null,
|
||||
pinOrganization: true,
|
||||
})
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
|
||||
expect(result.organizationId).toBeNull()
|
||||
expect(result.billedAccountUserId).toBe('user-1')
|
||||
})
|
||||
|
||||
it('allows org admins on a team plan to create organization workspaces', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce({
|
||||
organizationId: 'org-1',
|
||||
role: 'admin',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockGetOrganizationSubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'team_6000',
|
||||
status: 'active',
|
||||
})
|
||||
mockDbResults.value = [[{ userId: 'owner-1' }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
|
||||
expect(result.organizationId).toBe('org-1')
|
||||
expect(result.billedAccountUserId).toBe('owner-1')
|
||||
})
|
||||
|
||||
it('allows org admins to create organization workspaces when billing is disabled', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
mockGetUserOrganization.mockResolvedValueOnce({
|
||||
organizationId: 'org-1',
|
||||
role: 'admin',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockDbResults.value = [[{ userId: 'owner-1' }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
|
||||
expect(result.organizationId).toBe('org-1')
|
||||
expect(result.billedAccountUserId).toBe('owner-1')
|
||||
expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks non-admin org members from creating organization workspaces', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce({
|
||||
organizationId: 'org-1',
|
||||
role: 'member',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockGetOrganizationSubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'enterprise',
|
||||
status: 'active',
|
||||
})
|
||||
mockDbResults.value = [[{ userId: 'owner-1' }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.canCreate).toBe(false)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
|
||||
expect(result.reason).toContain('owners and admins')
|
||||
})
|
||||
|
||||
it('blocks users without org membership from creating workspaces in the active org context', async () => {
|
||||
mockDbResults.value = [[], [{ userId: 'owner-1' }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'external-user-1',
|
||||
activeOrganizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.canCreate).toBe(false)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
|
||||
expect(result.organizationId).toBe('org-1')
|
||||
expect(result.billedAccountUserId).toBe('owner-1')
|
||||
expect(result.reason).toContain('owners and admins')
|
||||
expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
|
||||
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceInvitePolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFeatureFlags.isBillingEnabled = true
|
||||
mockGetOrganizationSubscription.mockResolvedValue(null)
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
const baseState = {
|
||||
workspaceMode: WORKSPACE_MODE.PERSONAL,
|
||||
organizationId: null,
|
||||
billedAccountUserId: 'owner-1',
|
||||
ownerId: 'owner-1',
|
||||
} as const
|
||||
|
||||
it('allows invites unconditionally when billing is disabled', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
|
||||
const result = await getWorkspaceInvitePolicy(baseState)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.upgradeRequired).toBe(false)
|
||||
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks free personal workspaces with an upgrade prompt', async () => {
|
||||
const result = await getWorkspaceInvitePolicy(baseState)
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.upgradeRequired).toBe(true)
|
||||
expect(result.reason).toBe(UPGRADE_TO_INVITE_REASON)
|
||||
})
|
||||
|
||||
it('allows pro personal workspaces and defers the team upgrade to acceptance', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'pro_6000',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const result = await getWorkspaceInvitePolicy(baseState)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.requiresSeat).toBe(false)
|
||||
expect(result.upgradeRequired).toBe(false)
|
||||
})
|
||||
|
||||
it('allows team org workspaces without an invite-time seat gate', async () => {
|
||||
mockGetOrganizationSubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'team_6000',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.requiresSeat).toBe(false)
|
||||
expect(result.organizationId).toBe('org-1')
|
||||
})
|
||||
|
||||
it('keeps the fixed-seat gate for enterprise org workspaces', async () => {
|
||||
mockGetOrganizationSubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'enterprise',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.requiresSeat).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks org workspaces whose organization has no usable subscription', async () => {
|
||||
mockGetOrganizationSubscription.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.upgradeRequired).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks org workspaces without an organization id', async () => {
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.upgradeRequired).toBe(true)
|
||||
})
|
||||
|
||||
it('allows grandfathered workspaces when the billed user has a team plan', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'team_6000',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.upgradeRequired).toBe(false)
|
||||
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('owner-1')
|
||||
})
|
||||
|
||||
it('allows grandfathered workspaces when the billed user has a pro plan', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
|
||||
id: 'sub-1',
|
||||
plan: 'pro_6000',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.upgradeRequired).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks grandfathered workspaces when the billed user is on a free plan', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await getWorkspaceInvitePolicy({
|
||||
...baseState,
|
||||
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.upgradeRequired).toBe(true)
|
||||
expect(result.reason).toBe(UPGRADE_TO_INVITE_REASON)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,406 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, type WorkspaceMode, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { and, count, eq, isNull } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { getUserOrganization } from '@/lib/billing/organizations/membership'
|
||||
import type { PlanCategory } from '@/lib/billing/plan-helpers'
|
||||
import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
|
||||
|
||||
const logger = createLogger('WorkspacePolicy')
|
||||
|
||||
export const WORKSPACE_MODE = {
|
||||
PERSONAL: 'personal',
|
||||
ORGANIZATION: 'organization',
|
||||
GRANDFATHERED_SHARED: 'grandfathered_shared',
|
||||
} as const satisfies Record<string, WorkspaceMode>
|
||||
|
||||
interface WorkspaceOwnershipState {
|
||||
organizationId: string | null
|
||||
workspaceMode: WorkspaceMode
|
||||
billedAccountUserId: string
|
||||
ownerId: string
|
||||
}
|
||||
|
||||
export {
|
||||
CONTACT_OWNER_TO_UPGRADE_REASON,
|
||||
UPGRADE_TO_INVITE_REASON,
|
||||
} from '@/lib/workspaces/policy-constants'
|
||||
|
||||
export interface WorkspaceInvitePolicy {
|
||||
allowed: boolean
|
||||
reason: string | null
|
||||
requiresSeat: boolean
|
||||
organizationId: string | null
|
||||
upgradeRequired: boolean
|
||||
}
|
||||
|
||||
export interface WorkspaceCreationPolicy {
|
||||
canCreate: boolean
|
||||
workspaceMode: WorkspaceMode
|
||||
organizationId: string | null
|
||||
billedAccountUserId: string
|
||||
maxWorkspaces: number | null
|
||||
currentWorkspaceCount: number
|
||||
reason: string | null
|
||||
status: number
|
||||
}
|
||||
|
||||
interface GetWorkspaceCreationPolicyParams {
|
||||
userId: string
|
||||
activeOrganizationId?: string | null
|
||||
/**
|
||||
* When true, `activeOrganizationId` is authoritative: it is used exactly as given
|
||||
* (including `null`, which means a personal workspace) and never falls back to the
|
||||
* caller's membership org. Forks set this so the child always lands in the SOURCE's
|
||||
* org, not whatever org the acting user happens to belong to.
|
||||
*/
|
||||
pinOrganization?: boolean
|
||||
}
|
||||
|
||||
export function isOrganizationWorkspace(
|
||||
workspaceState: Pick<WorkspaceOwnershipState, 'workspaceMode' | 'organizationId'>
|
||||
): boolean {
|
||||
return (
|
||||
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
|
||||
workspaceState.organizationId !== null &&
|
||||
workspaceState.organizationId.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes whether new members can be invited to the given workspace
|
||||
* under the active product policy.
|
||||
*
|
||||
* Any paid billed account (Pro, Team, or Enterprise) may invite — the
|
||||
* seat, and any Pro→Team upgrade, is provisioned when the invitee
|
||||
* accepts, so invites are no longer seat-gated at this layer. Free
|
||||
* accounts are blocked with an upgrade tooltip because there is no
|
||||
* payment method to charge at acceptance.
|
||||
*
|
||||
* - `organization`: allowed; the org already holds a Team/Enterprise
|
||||
* subscription. Only Enterprise keeps an invite-time `requiresSeat`
|
||||
* gate (fixed seats).
|
||||
* - `personal` / `grandfathered_shared`: allowed when the billed user is
|
||||
* Pro/Team/Enterprise. For Pro, acceptance creates the org and moves the
|
||||
* subscription to the Team tier.
|
||||
*
|
||||
* Billing-disabled deployments always allow invites. Existing members
|
||||
* keep their access — this policy only governs *new* invitations.
|
||||
*/
|
||||
export async function getWorkspaceInvitePolicy(
|
||||
workspaceState: WorkspaceOwnershipState
|
||||
): Promise<WorkspaceInvitePolicy> {
|
||||
const billedPlanCategory = isBillingEnabled
|
||||
? await resolveBilledPlanCategory(workspaceState)
|
||||
: 'free'
|
||||
return evaluateWorkspaceInvitePolicy(workspaceState, { billedPlanCategory })
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure evaluator — given the billed account's resolved plan category,
|
||||
* returns the policy synchronously. Exposed so bulk callers (e.g. listing
|
||||
* every workspace a user can see) can batch the subscription lookups by
|
||||
* unique billed account user rather than re-querying per workspace.
|
||||
*/
|
||||
export function evaluateWorkspaceInvitePolicy(
|
||||
workspaceState: WorkspaceOwnershipState,
|
||||
context: { billedPlanCategory: PlanCategory }
|
||||
): WorkspaceInvitePolicy {
|
||||
if (!isBillingEnabled) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: null,
|
||||
requiresSeat: false,
|
||||
organizationId: workspaceState.organizationId,
|
||||
upgradeRequired: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION) {
|
||||
if (workspaceState.organizationId === null || context.billedPlanCategory === 'free') {
|
||||
return blockInvite(workspaceState.organizationId)
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: null,
|
||||
requiresSeat: context.billedPlanCategory === 'enterprise',
|
||||
organizationId: workspaceState.organizationId,
|
||||
upgradeRequired: false,
|
||||
}
|
||||
}
|
||||
|
||||
switch (context.billedPlanCategory) {
|
||||
case 'pro':
|
||||
case 'team':
|
||||
return {
|
||||
allowed: true,
|
||||
reason: null,
|
||||
requiresSeat: false,
|
||||
organizationId: workspaceState.organizationId,
|
||||
upgradeRequired: false,
|
||||
}
|
||||
case 'enterprise':
|
||||
return {
|
||||
allowed: true,
|
||||
reason: null,
|
||||
requiresSeat: true,
|
||||
organizationId: workspaceState.organizationId,
|
||||
upgradeRequired: false,
|
||||
}
|
||||
default:
|
||||
return blockInvite(workspaceState.organizationId)
|
||||
}
|
||||
}
|
||||
|
||||
function blockInvite(organizationId: string | null): WorkspaceInvitePolicy {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: UPGRADE_TO_INVITE_REASON,
|
||||
requiresSeat: false,
|
||||
organizationId,
|
||||
upgradeRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBilledPlanCategory(
|
||||
workspaceState: WorkspaceOwnershipState
|
||||
): Promise<PlanCategory> {
|
||||
if (
|
||||
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
|
||||
workspaceState.organizationId
|
||||
) {
|
||||
return getInvitePlanCategoryForOrganization(workspaceState.organizationId)
|
||||
}
|
||||
return getInvitePlanCategoryForUser(workspaceState.billedAccountUserId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the invite-governing plan category for an organization from its
|
||||
* subscription. Exposed so bulk callers can batch by unique organization id.
|
||||
* Returns `'free'` when there is no usable subscription so lapsed orgs are
|
||||
* blocked consistently with accept-time provisioning.
|
||||
*/
|
||||
export async function getInvitePlanCategoryForOrganization(
|
||||
organizationId: string
|
||||
): Promise<PlanCategory> {
|
||||
try {
|
||||
const orgSub = await getOrganizationSubscription(organizationId)
|
||||
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return 'free'
|
||||
return getPlanType(orgSub.plan)
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve organization subscription for invite policy', {
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
return 'free'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the invite-governing plan category for a single billed account
|
||||
* user. Exposed so bulk callers can batch by unique user id. Returns
|
||||
* `'free'` when there is no usable paid subscription.
|
||||
*/
|
||||
export async function getInvitePlanCategoryForUser(userId: string): Promise<PlanCategory> {
|
||||
try {
|
||||
const sub = await getHighestPrioritySubscription(userId)
|
||||
if (!sub || !hasUsableSubscriptionStatus(sub.status)) return 'free'
|
||||
return getPlanType(sub.plan)
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve subscription for invite policy', { userId, error })
|
||||
return 'free'
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkspaceCreationPolicy({
|
||||
userId,
|
||||
activeOrganizationId,
|
||||
pinOrganization = false,
|
||||
}: GetWorkspaceCreationPolicyParams): Promise<WorkspaceCreationPolicy> {
|
||||
const membership = await getUserOrganization(userId)
|
||||
const organizationId = pinOrganization
|
||||
? (activeOrganizationId ?? null)
|
||||
: (activeOrganizationId ?? membership?.organizationId ?? null)
|
||||
const orgRole =
|
||||
organizationId == null
|
||||
? undefined
|
||||
: membership?.organizationId === organizationId
|
||||
? membership.role
|
||||
: (
|
||||
await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
)[0]?.role
|
||||
|
||||
if (activeOrganizationId && !orgRole) {
|
||||
const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId)
|
||||
|
||||
return {
|
||||
canCreate: false,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId: activeOrganizationId,
|
||||
billedAccountUserId,
|
||||
maxWorkspaces: null,
|
||||
currentWorkspaceCount: 0,
|
||||
reason: 'Only organization owners and admins can create organization workspaces.',
|
||||
status: 403,
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
if (organizationId && orgRole) {
|
||||
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
|
||||
|
||||
if (!isOrgAdminRole(orgRole)) {
|
||||
return {
|
||||
canCreate: false,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId,
|
||||
billedAccountUserId,
|
||||
maxWorkspaces: null,
|
||||
currentWorkspaceCount: 0,
|
||||
reason: 'Only organization owners and admins can create organization workspaces.',
|
||||
status: 403,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canCreate: true,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId,
|
||||
billedAccountUserId,
|
||||
maxWorkspaces: null,
|
||||
currentWorkspaceCount: 0,
|
||||
reason: null,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
|
||||
|
||||
return {
|
||||
canCreate: true,
|
||||
workspaceMode: WORKSPACE_MODE.PERSONAL,
|
||||
organizationId: null,
|
||||
billedAccountUserId: userId,
|
||||
maxWorkspaces: null,
|
||||
currentWorkspaceCount,
|
||||
reason: null,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
if (organizationId && orgRole) {
|
||||
const organizationSubscription = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (
|
||||
organizationSubscription &&
|
||||
hasUsableSubscriptionStatus(organizationSubscription.status) &&
|
||||
(isTeam(organizationSubscription.plan) || isEnterprise(organizationSubscription.plan))
|
||||
) {
|
||||
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
|
||||
|
||||
if (!isOrgAdminRole(orgRole)) {
|
||||
return {
|
||||
canCreate: false,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId,
|
||||
billedAccountUserId,
|
||||
maxWorkspaces: null,
|
||||
currentWorkspaceCount: 0,
|
||||
reason: 'Only organization owners and admins can create organization workspaces.',
|
||||
status: 403,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canCreate: true,
|
||||
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
|
||||
organizationId,
|
||||
billedAccountUserId,
|
||||
maxWorkspaces: null,
|
||||
currentWorkspaceCount: 0,
|
||||
reason: null,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highestPrioritySubscription = await getHighestPrioritySubscription(userId)
|
||||
const plan = highestPrioritySubscription?.plan
|
||||
const maxWorkspaces = isMax(plan) ? 10 : isPro(plan) ? 3 : 1
|
||||
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
|
||||
|
||||
if (currentWorkspaceCount >= maxWorkspaces) {
|
||||
return {
|
||||
canCreate: false,
|
||||
workspaceMode: WORKSPACE_MODE.PERSONAL,
|
||||
organizationId: null,
|
||||
billedAccountUserId: userId,
|
||||
maxWorkspaces,
|
||||
currentWorkspaceCount,
|
||||
reason: `This plan supports up to ${maxWorkspaces} personal workspace${maxWorkspaces === 1 ? '' : 's'}.`,
|
||||
status: 403,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canCreate: true,
|
||||
workspaceMode: WORKSPACE_MODE.PERSONAL,
|
||||
organizationId: null,
|
||||
billedAccountUserId: userId,
|
||||
maxWorkspaces,
|
||||
currentWorkspaceCount,
|
||||
reason: null,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
async function countNonOrganizationOwnedWorkspaces(userId: string): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ value: count() })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.ownerId, userId), isNull(workspace.organizationId)))
|
||||
|
||||
return result?.value ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the userId of the organization owner, or `null` if the
|
||||
* organization has no owner row. Unexpected DB errors propagate to the
|
||||
* caller so data-integrity issues surface loudly rather than being
|
||||
* silently fallen back to the caller's identity.
|
||||
*/
|
||||
export async function getOrganizationOwnerId(organizationId: string): Promise<string | null> {
|
||||
const [ownerMembership] = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
return ownerMembership?.userId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `getOrganizationOwnerId` but throws when no owner row exists.
|
||||
* Use when the caller needs a guaranteed billed-account userId — every
|
||||
* Better Auth organization is expected to have exactly one owner, so a
|
||||
* missing owner is a data-integrity issue that should surface loudly.
|
||||
*/
|
||||
async function requireOrganizationOwnerId(organizationId: string): Promise<string> {
|
||||
const ownerId = await getOrganizationOwnerId(organizationId)
|
||||
if (!ownerId) {
|
||||
logger.error('Organization is missing its owner membership row', { organizationId })
|
||||
throw new Error(`Organization ${organizationId} has no owner membership`)
|
||||
}
|
||||
return ownerId
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { db } from '@sim/db'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { select: vi.fn() },
|
||||
}))
|
||||
|
||||
import {
|
||||
listAccessibleWorkspaceRowsForUser,
|
||||
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
||||
} from '@/lib/workspaces/utils'
|
||||
|
||||
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
|
||||
|
||||
function createMockChain(finalResult: unknown) {
|
||||
const chain: any = {}
|
||||
chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult))
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
return chain
|
||||
}
|
||||
|
||||
function createSelectChain(result: unknown) {
|
||||
const limit = vi.fn().mockResolvedValue(result)
|
||||
const where = vi.fn().mockReturnValue({ limit })
|
||||
const from = vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(result),
|
||||
limit,
|
||||
})
|
||||
|
||||
return { from, where, limit }
|
||||
}
|
||||
|
||||
function createGroupedSelectChain(result: unknown) {
|
||||
const groupBy = vi.fn().mockResolvedValue(result)
|
||||
const where = vi.fn().mockReturnValue({ groupBy })
|
||||
const from = vi.fn().mockReturnValue({ where })
|
||||
|
||||
return { from, where, groupBy }
|
||||
}
|
||||
|
||||
function createUpdateChain(result: unknown) {
|
||||
const returning = vi.fn().mockResolvedValue(result)
|
||||
const where = vi.fn().mockReturnValue({ returning })
|
||||
const set = vi.fn().mockReturnValue({ where })
|
||||
|
||||
return { set, where, returning }
|
||||
}
|
||||
|
||||
describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reassigns departing member workflows to the workspace billed account', async () => {
|
||||
const workspaceSelect = createSelectChain([
|
||||
{ id: 'workspace-1', billedAccountUserId: 'billed-1' },
|
||||
])
|
||||
const workflowCountSelect = createGroupedSelectChain([
|
||||
{ workspaceId: 'workspace-1', workflowCount: 2 },
|
||||
])
|
||||
const workflowUpdate = createUpdateChain([])
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
|
||||
update: vi.fn().mockReturnValue(workflowUpdate),
|
||||
}
|
||||
|
||||
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx: tx as any,
|
||||
workspaceIds: ['workspace-1'],
|
||||
departingUserId: 'departing-1',
|
||||
})
|
||||
|
||||
expect(tx.update).toHaveBeenCalledTimes(1)
|
||||
expect(workflowUpdate.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ updatedAt: expect.any(Date) })
|
||||
)
|
||||
expect(result).toEqual({
|
||||
reassigned: [{ workspaceId: 'workspace-1', newWorkflowUserId: 'billed-1', workflowCount: 2 }],
|
||||
unresolved: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a workspace unresolved when the departing member is the billed account', async () => {
|
||||
const workspaceSelect = createSelectChain([
|
||||
{ id: 'workspace-1', billedAccountUserId: 'departing-1' },
|
||||
])
|
||||
const workflowCountSelect = createGroupedSelectChain([
|
||||
{ workspaceId: 'workspace-1', workflowCount: 1 },
|
||||
])
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
|
||||
update: vi.fn(),
|
||||
}
|
||||
|
||||
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx: tx as any,
|
||||
workspaceIds: ['workspace-1'],
|
||||
departingUserId: 'departing-1',
|
||||
})
|
||||
|
||||
expect(tx.update).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] })
|
||||
})
|
||||
|
||||
it('does not mark a workspace unresolved when the departing billing account owns no workflows', async () => {
|
||||
const workspaceSelect = createSelectChain([
|
||||
{ id: 'workspace-1', billedAccountUserId: 'departing-1' },
|
||||
])
|
||||
const workflowCountSelect = createGroupedSelectChain([])
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
|
||||
update: vi.fn(),
|
||||
}
|
||||
|
||||
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx: tx as any,
|
||||
workspaceIds: ['workspace-1'],
|
||||
departingUserId: 'departing-1',
|
||||
})
|
||||
|
||||
expect(tx.update).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ reassigned: [], unresolved: [] })
|
||||
})
|
||||
|
||||
it('marks a workspace unresolved when no billed account is configured', async () => {
|
||||
const workspaceSelect = createSelectChain([{ id: 'workspace-1', billedAccountUserId: null }])
|
||||
const workflowCountSelect = createGroupedSelectChain([
|
||||
{ workspaceId: 'workspace-1', workflowCount: 1 },
|
||||
])
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
|
||||
update: vi.fn(),
|
||||
}
|
||||
|
||||
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx: tx as any,
|
||||
workspaceIds: ['workspace-1'],
|
||||
departingUserId: 'departing-1',
|
||||
})
|
||||
|
||||
expect(tx.update).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAccessibleWorkspaceRowsForUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('elevates an org admin to admin on an org workspace where they hold a lower explicit grant', async () => {
|
||||
const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' }
|
||||
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(createMockChain([{ workspace: orgWorkspace, permissionType: 'write' }]))
|
||||
.mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }]))
|
||||
.mockReturnValueOnce(createMockChain([orgWorkspace]))
|
||||
|
||||
const rows = await listAccessibleWorkspaceRowsForUser('user-1', 'active')
|
||||
|
||||
expect(rows).toEqual([{ workspace: orgWorkspace, permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
it('keeps a lower explicit grant on a workspace owned by a different organization', async () => {
|
||||
const externalWorkspace = {
|
||||
id: 'ws-ext',
|
||||
name: 'External',
|
||||
ownerId: 'owner-y',
|
||||
organizationId: 'org-2',
|
||||
}
|
||||
const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' }
|
||||
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(
|
||||
createMockChain([{ workspace: externalWorkspace, permissionType: 'write' }])
|
||||
)
|
||||
.mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }]))
|
||||
.mockReturnValueOnce(createMockChain([orgWorkspace]))
|
||||
|
||||
const rows = await listAccessibleWorkspaceRowsForUser('user-1', 'active')
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ workspace: externalWorkspace, permissionType: 'write' },
|
||||
{ workspace: orgWorkspace, permissionType: 'admin' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,507 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, workflow, workspace as workspaceTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const logger = createLogger('WorkspaceUtils')
|
||||
|
||||
export interface WorkspaceBillingSettings {
|
||||
billedAccountUserId: string | null
|
||||
allowPersonalApiKeys: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export async function getWorkspaceBillingSettings(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceBillingSettings | null> {
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(and(eq(workspaceTable.id, workspaceId), isNull(workspaceTable.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
billedAccountUserId: rows[0].billedAccountUserId ?? null,
|
||||
allowPersonalApiKeys: rows[0].allowPersonalApiKeys ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkspaceBilledAccountUserId(workspaceId: string): Promise<string | null> {
|
||||
const settings = await getWorkspaceBillingSettings(workspaceId)
|
||||
return settings?.billedAccountUserId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces the user administers purely through organization owner/admin role,
|
||||
* with no explicit permission row required. Empty when the user is not an org
|
||||
* owner/admin. Implements the workspace-permission inheritance model.
|
||||
*/
|
||||
export async function getOrgAdminWorkspaceRows(
|
||||
userId: string,
|
||||
scope: WorkspaceScope = 'active'
|
||||
): Promise<Array<typeof workspaceTable.$inferSelect>> {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!membership || !isOrgAdminRole(membership.role)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const orgFilter = eq(workspaceTable.organizationId, membership.organizationId)
|
||||
const where =
|
||||
scope === 'all'
|
||||
? orgFilter
|
||||
: scope === 'archived'
|
||||
? and(orgFilter, sql`${workspaceTable.archivedAt} IS NOT NULL`)
|
||||
: and(orgFilter, isNull(workspaceTable.archivedAt))
|
||||
|
||||
return db.select().from(workspaceTable).where(where).orderBy(desc(workspaceTable.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every workspace a user can access: explicit permission grants plus workspaces
|
||||
* derived from organization owner/admin role. Deduped with explicit rows first.
|
||||
*/
|
||||
export async function listAccessibleWorkspaceRowsForUser(
|
||||
userId: string,
|
||||
scope: WorkspaceScope = 'active'
|
||||
): Promise<
|
||||
Array<{ workspace: typeof workspaceTable.$inferSelect; permissionType: PermissionType }>
|
||||
> {
|
||||
const explicit = await db
|
||||
.select({ workspace: workspaceTable, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.innerJoin(workspaceTable, eq(permissions.entityId, workspaceTable.id))
|
||||
.where(
|
||||
scope === 'all'
|
||||
? and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))
|
||||
: scope === 'archived'
|
||||
? and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
sql`${workspaceTable.archivedAt} IS NOT NULL`
|
||||
)
|
||||
: and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
isNull(workspaceTable.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workspaceTable.createdAt))
|
||||
|
||||
const orgRows = await getOrgAdminWorkspaceRows(userId, scope)
|
||||
if (orgRows.length === 0) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const orgWorkspaceIds = new Set(orgRows.map((ws) => ws.id))
|
||||
const seen = new Set(explicit.map((row) => row.workspace.id))
|
||||
|
||||
const elevatedExplicit = explicit.map((row) =>
|
||||
orgWorkspaceIds.has(row.workspace.id) ? { ...row, permissionType: 'admin' as const } : row
|
||||
)
|
||||
const derived = orgRows
|
||||
.filter((ws) => !seen.has(ws.id))
|
||||
.map((ws) => ({ workspace: ws, permissionType: 'admin' as const }))
|
||||
|
||||
return [...elevatedExplicit, ...derived]
|
||||
}
|
||||
|
||||
export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = 'active') {
|
||||
const rows = await listAccessibleWorkspaceRowsForUser(userId, scope)
|
||||
|
||||
return rows.map(({ workspace: ws, permissionType }) => ({
|
||||
workspaceId: ws.id,
|
||||
workspaceName: ws.name,
|
||||
role: ws.ownerId === userId ? 'owner' : permissionType,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface ReassignBilledAccountResult {
|
||||
reassigned: Array<{ workspaceId: string; newBilledAccountUserId: string }>
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
export interface ReassignWorkflowOwnershipResult {
|
||||
reassigned: Array<{ workspaceId: string; newWorkflowUserId: string; workflowCount: number }>
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
export const WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR =
|
||||
'Cannot remove the workspace billing account. Please reassign billing first.'
|
||||
|
||||
export class WorkspaceBillingAccountRemovalError extends Error {
|
||||
constructor() {
|
||||
super(WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR)
|
||||
this.name = 'WorkspaceBillingAccountRemovalError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
|
||||
tx,
|
||||
workspaceId,
|
||||
departingUserId,
|
||||
}: {
|
||||
tx: DbOrTx
|
||||
workspaceId: string
|
||||
departingUserId: string
|
||||
}): Promise<boolean> {
|
||||
const [workspaceRow] = await tx
|
||||
.select({
|
||||
ownerId: workspaceTable.ownerId,
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow || workspaceRow.ownerId !== departingUserId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newOwnerId = workspaceRow.billedAccountUserId
|
||||
if (!newOwnerId || newOwnerId === departingUserId) {
|
||||
throw new WorkspaceBillingAccountRemovalError()
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workspaceTable)
|
||||
.set({ ownerId: newOwnerId, updatedAt: new Date() })
|
||||
.where(eq(workspaceTable.id, workspaceId))
|
||||
|
||||
const [existingNewOwnerPermission] = await tx
|
||||
.select({ id: permissions.id })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, newOwnerId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingNewOwnerPermission) {
|
||||
await tx
|
||||
.update(permissions)
|
||||
.set({ permissionType: 'admin', updatedAt: new Date() })
|
||||
.where(eq(permissions.id, existingNewOwnerPermission.id))
|
||||
return true
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await tx.insert(permissions).values({
|
||||
id: generateId(),
|
||||
userId: newOwnerId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassigns workflows owned by a user who is about to lose access to one or more workspaces.
|
||||
*
|
||||
* Workflow execution, webhook provider config, and environment variables intentionally resolve
|
||||
* through `workflow.userId`, so that identity must remain an active workspace identity. The
|
||||
* replacement is the workspace billed account: the same stable identity used for server-side
|
||||
* billing/permission actor resolution, and one the member-removal routes protect from removal.
|
||||
*/
|
||||
export async function reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx,
|
||||
workspaceIds,
|
||||
departingUserId,
|
||||
}: {
|
||||
tx: DbOrTx
|
||||
workspaceIds: string[]
|
||||
departingUserId: string
|
||||
}): Promise<ReassignWorkflowOwnershipResult> {
|
||||
const uniqueWorkspaceIds = Array.from(new Set(workspaceIds.filter(Boolean)))
|
||||
if (uniqueWorkspaceIds.length === 0) {
|
||||
return { reassigned: [], unresolved: [] }
|
||||
}
|
||||
|
||||
const workspaceRows = await tx
|
||||
.select({
|
||||
id: workspaceTable.id,
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(inArray(workspaceTable.id, uniqueWorkspaceIds))
|
||||
|
||||
const reassigned: ReassignWorkflowOwnershipResult['reassigned'] = []
|
||||
const unresolved: string[] = []
|
||||
const reassignmentWorkspaceIds: string[] = []
|
||||
const workflowCounts = await tx
|
||||
.select({
|
||||
workspaceId: workflow.workspaceId,
|
||||
workflowCount: count(workflow.id),
|
||||
})
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(eq(workflow.userId, departingUserId), inArray(workflow.workspaceId, uniqueWorkspaceIds))
|
||||
)
|
||||
.groupBy(workflow.workspaceId)
|
||||
|
||||
const workflowCountsByWorkspaceId = new Map<string, number>()
|
||||
for (const { workspaceId, workflowCount } of workflowCounts) {
|
||||
if (!workspaceId || workflowCount === 0) continue
|
||||
workflowCountsByWorkspaceId.set(workspaceId, workflowCount)
|
||||
}
|
||||
|
||||
for (const ws of workspaceRows) {
|
||||
const workflowCount = workflowCountsByWorkspaceId.get(ws.id) ?? 0
|
||||
if (workflowCount === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const replacementUserId =
|
||||
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
|
||||
|
||||
if (!replacementUserId) {
|
||||
unresolved.push(ws.id)
|
||||
continue
|
||||
}
|
||||
|
||||
reassignmentWorkspaceIds.push(ws.id)
|
||||
}
|
||||
|
||||
if (reassignmentWorkspaceIds.length > 0) {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
userId: sql<string>`(
|
||||
select ${workspaceTable.billedAccountUserId}
|
||||
from ${workspaceTable}
|
||||
where ${workspaceTable.id} = ${workflow.workspaceId}
|
||||
)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.userId, departingUserId),
|
||||
inArray(workflow.workspaceId, reassignmentWorkspaceIds)
|
||||
)
|
||||
)
|
||||
|
||||
const billedAccountByWorkspaceId = new Map(
|
||||
workspaceRows.map((ws) => [ws.id, ws.billedAccountUserId])
|
||||
)
|
||||
for (const workspaceId of reassignmentWorkspaceIds) {
|
||||
const workflowCount = workflowCountsByWorkspaceId.get(workspaceId) ?? 0
|
||||
const newWorkflowUserId = billedAccountByWorkspaceId.get(workspaceId)
|
||||
if (!newWorkflowUserId) continue
|
||||
reassigned.push({
|
||||
workspaceId,
|
||||
newWorkflowUserId,
|
||||
workflowCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (reassigned.length > 0 || unresolved.length > 0) {
|
||||
logger.info('Reassigned workflow ownership for removed workspace member', {
|
||||
departingUserId,
|
||||
reassigned,
|
||||
unresolved,
|
||||
})
|
||||
}
|
||||
|
||||
return { reassigned, unresolved }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassigns `billedAccountUserId` on every workspace that points to `departingUserId` to
|
||||
* another eligible user, so the user can be deleted without violating the `workspace.billed_account_user_id`
|
||||
* foreign key (`ON DELETE NO ACTION`).
|
||||
*
|
||||
* Preference order for the replacement:
|
||||
* 1. The workspace owner (if different from the departing user)
|
||||
* 2. Any existing workspace admin
|
||||
*
|
||||
* Returns the list of workspaces that could not be reassigned (no owner + no admin). Callers should
|
||||
* block user deletion when `unresolved.length > 0` so we never leave an orphaned billing reference.
|
||||
*/
|
||||
export async function reassignBilledAccountForUser(
|
||||
departingUserId: string
|
||||
): Promise<ReassignBilledAccountResult> {
|
||||
const billedWorkspaces = await db
|
||||
.select({
|
||||
id: workspaceTable.id,
|
||||
ownerId: workspaceTable.ownerId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.billedAccountUserId, departingUserId))
|
||||
|
||||
if (billedWorkspaces.length === 0) {
|
||||
return { reassigned: [], unresolved: [] }
|
||||
}
|
||||
|
||||
const reassigned: ReassignBilledAccountResult['reassigned'] = []
|
||||
const unresolved: string[] = []
|
||||
|
||||
for (const ws of billedWorkspaces) {
|
||||
let replacement: string | null = ws.ownerId !== departingUserId ? ws.ownerId : null
|
||||
|
||||
if (!replacement) {
|
||||
const [admin] = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, ws.id),
|
||||
eq(permissions.permissionType, 'admin'),
|
||||
ne(permissions.userId, departingUserId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
replacement = admin?.userId ?? null
|
||||
}
|
||||
|
||||
if (!replacement) {
|
||||
unresolved.push(ws.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workspaceTable)
|
||||
.set({ billedAccountUserId: replacement, updatedAt: new Date() })
|
||||
.where(eq(workspaceTable.id, ws.id))
|
||||
|
||||
reassigned.push({ workspaceId: ws.id, newBilledAccountUserId: replacement })
|
||||
}
|
||||
|
||||
if (reassigned.length > 0) {
|
||||
logger.info('Reassigned workspace billed account for departing user', {
|
||||
departingUserId,
|
||||
reassignedCount: reassigned.length,
|
||||
unresolvedCount: unresolved.length,
|
||||
})
|
||||
}
|
||||
|
||||
return { reassigned, unresolved }
|
||||
}
|
||||
|
||||
export interface ReassignOwnedWorkspacesResult {
|
||||
reassigned: Array<{ workspaceId: string; newOwnerId: string }>
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassigns `ownerId` on every workspace owned by `departingUserId` to another
|
||||
* eligible user, so the user can be deleted without the `workspace.owner_id`
|
||||
* `ON DELETE CASCADE` silently deleting their workspaces.
|
||||
*
|
||||
* Preference order for the replacement:
|
||||
* 1. The workspace billed account (if different from the departing user)
|
||||
* 2. Any other workspace admin
|
||||
*
|
||||
* Returns workspaces that could not be reassigned (no distinct billed account and
|
||||
* no other admin). Callers MUST block user deletion when `unresolved.length > 0`
|
||||
* so the cascade can never nuke a workspace.
|
||||
*/
|
||||
export async function reassignOwnedWorkspacesForUser(
|
||||
departingUserId: string
|
||||
): Promise<ReassignOwnedWorkspacesResult> {
|
||||
const ownedWorkspaces = await db
|
||||
.select({
|
||||
id: workspaceTable.id,
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.ownerId, departingUserId))
|
||||
|
||||
if (ownedWorkspaces.length === 0) {
|
||||
return { reassigned: [], unresolved: [] }
|
||||
}
|
||||
|
||||
const reassigned: ReassignOwnedWorkspacesResult['reassigned'] = []
|
||||
const unresolved: string[] = []
|
||||
|
||||
for (const ws of ownedWorkspaces) {
|
||||
let replacement: string | null =
|
||||
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
|
||||
|
||||
if (!replacement) {
|
||||
const [admin] = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, ws.id),
|
||||
eq(permissions.permissionType, 'admin'),
|
||||
ne(permissions.userId, departingUserId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
replacement = admin?.userId ?? null
|
||||
}
|
||||
|
||||
if (!replacement) {
|
||||
unresolved.push(ws.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(workspaceTable)
|
||||
.set({ ownerId: replacement, updatedAt: now })
|
||||
.where(eq(workspaceTable.id, ws.id))
|
||||
|
||||
// Owners are admins — guarantee the new owner holds an admin permission row.
|
||||
await db
|
||||
.insert(permissions)
|
||||
.values({
|
||||
id: generateId(),
|
||||
userId: replacement,
|
||||
entityType: 'workspace',
|
||||
entityId: ws.id,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [permissions.userId, permissions.entityType, permissions.entityId],
|
||||
set: { permissionType: 'admin', updatedAt: now },
|
||||
})
|
||||
|
||||
reassigned.push({ workspaceId: ws.id, newOwnerId: replacement })
|
||||
}
|
||||
|
||||
if (reassigned.length > 0) {
|
||||
logger.info('Reassigned workspace ownership for departing user', {
|
||||
departingUserId,
|
||||
reassignedCount: reassigned.length,
|
||||
unresolvedCount: unresolved.length,
|
||||
})
|
||||
}
|
||||
|
||||
return { reassigned, unresolved }
|
||||
}
|
||||
Reference in New Issue
Block a user