Files
simstudioai--sim/apps/sim/app/api/workspaces/route.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

451 lines
14 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import type { PlanCategory } from '@/lib/billing/plan-helpers'
import { PlatformEvents } from '@/lib/core/telemetry'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
import {
CONTACT_OWNER_TO_UPGRADE_REASON,
evaluateWorkspaceInvitePolicy,
getInvitePlanCategoryForOrganization,
getInvitePlanCategoryForUser,
getWorkspaceCreationPolicy,
getWorkspaceInvitePolicy,
UPGRADE_TO_INVITE_REASON,
WORKSPACE_MODE,
} from '@/lib/workspaces/policy'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
const logger = createLogger('Workspaces')
// Get all workspaces for the current user
export const GET = withRouteHandler(async (request: Request) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const activeOrganizationId =
(session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null
const creationPolicy = await getWorkspaceCreationPolicy({
userId: session.user.id,
activeOrganizationId,
})
const scopeResult = listWorkspacesQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams.entries())
)
if (!scopeResult.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: scopeResult.error.issues },
{ status: 400 }
)
}
const { scope } = scopeResult.data
const settingsQuery = db
.select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId })
.from(settings)
.where(eq(settings.userId, session.user.id))
.limit(1)
const [userWorkspaces, userSettings] = await Promise.all([
listAccessibleWorkspaceRowsForUser(session.user.id, scope),
settingsQuery,
])
const lastActiveWorkspaceId = userSettings[0]?.lastActiveWorkspaceId ?? null
if (scope === 'active' && userWorkspaces.length === 0) {
if (!creationPolicy.canCreate) {
return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy })
}
const defaultWorkspace = await createDefaultWorkspace(
session.user.id,
session.user.name,
creationPolicy
)
await migrateExistingWorkflows(session.user.id, defaultWorkspace.id)
const refreshedCreationPolicy = await getWorkspaceCreationPolicy({
userId: session.user.id,
activeOrganizationId,
})
return NextResponse.json({
workspaces: [defaultWorkspace],
lastActiveWorkspaceId,
creationPolicy: refreshedCreationPolicy,
})
}
if (scope === 'active') {
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
}
const nonOrgBilledUserIds = [
...new Set(
userWorkspaces
.filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
.map(({ workspace: ws }) => ws.billedAccountUserId)
),
]
const orgIds = [
...new Set(
userWorkspaces
.filter(
({ workspace: ws }) =>
ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId
)
.map(({ workspace: ws }) => ws.organizationId as string)
),
]
const planCategoryByBilledUser = new Map<string, PlanCategory>()
const planCategoryByOrg = new Map<string, PlanCategory>()
await Promise.all([
...nonOrgBilledUserIds.map(async (userId) => {
planCategoryByBilledUser.set(userId, await getInvitePlanCategoryForUser(userId))
}),
...orgIds.map(async (orgId) => {
planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId))
}),
])
const workspacesWithPermissions = userWorkspaces.map(
({ workspace: workspaceDetails, permissionType }) => {
const billedPlanCategory: PlanCategory =
workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION
? workspaceDetails.organizationId
? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free')
: 'free'
: (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free')
const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory })
const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
const inviteDisabledReason = invitePolicy.allowed
? null
: callerIsBilledUser
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
: CONTACT_OWNER_TO_UPGRADE_REASON
return {
...workspaceDetails,
role:
workspaceDetails.ownerId === session.user.id
? 'owner'
: permissionType === 'admin'
? 'admin'
: 'member',
permissions: permissionType,
inviteMembersEnabled: invitePolicy.allowed,
inviteDisabledReason,
inviteUpgradeRequired: canActOnUpgrade,
}
}
)
return NextResponse.json({
workspaces: workspacesWithPermissions,
lastActiveWorkspaceId,
creationPolicy,
})
})
// POST /api/workspaces - Create a new workspace
export const POST = withRouteHandler(async (req: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(createWorkspaceContract, req, {})
if (!parsed.success) return parsed.response
const { name, color, skipDefaultWorkflow } = parsed.data.body
const activeOrganizationId =
(session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null
const creationPolicy = await getWorkspaceCreationPolicy({
userId: session.user.id,
activeOrganizationId,
})
if (!creationPolicy.canCreate) {
return NextResponse.json(
{ error: creationPolicy.reason || 'Workspace creation is not available.' },
{ status: creationPolicy.status }
)
}
const newWorkspace = await createWorkspace({
userId: session.user.id,
name,
skipDefaultWorkflow,
explicitColor: color,
organizationId: creationPolicy.organizationId,
workspaceMode: creationPolicy.workspaceMode,
billedAccountUserId: creationPolicy.billedAccountUserId,
})
captureServerEvent(
session.user.id,
'workspace_created',
{
workspace_id: newWorkspace.id,
name: newWorkspace.name,
workspace_mode: newWorkspace.workspaceMode,
organization_id: newWorkspace.organizationId,
},
{
groups: { workspace: newWorkspace.id },
setOnce: { first_workspace_created_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: newWorkspace.id,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.WORKSPACE_CREATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: newWorkspace.id,
resourceName: newWorkspace.name,
description: `Created workspace "${newWorkspace.name}"`,
metadata: {
name: newWorkspace.name,
color: newWorkspace.color,
workspaceMode: newWorkspace.workspaceMode,
organizationId: newWorkspace.organizationId,
},
request: req,
})
return NextResponse.json({ workspace: newWorkspace })
} catch (error) {
logger.error('Error creating workspace:', error)
return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 })
}
})
async function createDefaultWorkspace(
userId: string,
userName: string | null | undefined,
creationPolicy: {
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
}
) {
const firstName = userName?.split(' ')[0] || null
const workspaceName = firstName ? `${firstName}'s Workspace` : 'My Workspace'
return createWorkspace({
userId,
name: workspaceName,
organizationId: creationPolicy.organizationId,
workspaceMode: creationPolicy.workspaceMode,
billedAccountUserId: creationPolicy.billedAccountUserId,
})
}
interface CreateWorkspaceParams {
userId: string
name: string
skipDefaultWorkflow?: boolean
explicitColor?: string
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
}
async function createWorkspace({
userId,
name,
skipDefaultWorkflow = false,
explicitColor,
organizationId,
workspaceMode,
billedAccountUserId,
}: CreateWorkspaceParams) {
const workspaceId = generateId()
const workflowId = generateId()
const now = new Date()
const color = explicitColor || getRandomWorkspaceColor()
try {
await db.transaction(async (tx) => {
await tx.insert(workspace).values({
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
})
const permissionRows = [
{
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
},
]
if (
workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
billedAccountUserId &&
billedAccountUserId !== userId
) {
permissionRows.push({
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId: billedAccountUserId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
})
}
await tx.insert(permissions).values(permissionRows)
if (!skipDefaultWorkflow) {
await tx.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
}
logger.info(
skipDefaultWorkflow
? `Created ${workspaceMode} workspace ${workspaceId} for user ${userId}`
: `Created ${workspaceMode} workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
)
})
} catch (error) {
logger.error(`Failed to create workspace ${workspaceId}:`, error)
throw error
}
try {
PlatformEvents.workspaceCreated({
workspaceId,
userId,
name,
})
} catch {
// Telemetry should not fail the operation
}
const invitePolicy = await getWorkspaceInvitePolicy({
organizationId,
workspaceMode,
billedAccountUserId,
ownerId: userId,
})
const callerIsBilledUser = billedAccountUserId === userId
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
const inviteDisabledReason = invitePolicy.allowed
? null
: callerIsBilledUser
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
: CONTACT_OWNER_TO_UPGRADE_REASON
return {
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
role: 'owner',
permissions: 'admin',
inviteMembersEnabled: invitePolicy.allowed,
inviteDisabledReason,
inviteUpgradeRequired: canActOnUpgrade,
}
}
async function migrateExistingWorkflows(userId: string, workspaceId: string) {
const orphanedWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
if (orphanedWorkflows.length === 0) {
return // No orphaned workflows to migrate
}
logger.info(
`Migrating ${orphanedWorkflows.length} workflows to workspace ${workspaceId} for user ${userId}`
)
await db
.update(workflow)
.set({
workspaceId: workspaceId,
updatedAt: new Date(),
})
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
}
async function ensureWorkflowsHaveWorkspace(userId: string, defaultWorkspaceId: string) {
const orphanedWorkflows = await db
.select()
.from(workflow)
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
if (orphanedWorkflows.length > 0) {
await db
.update(workflow)
.set({
workspaceId: defaultWorkspaceId,
updatedAt: new Date(),
})
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
logger.info(`Fixed ${orphanedWorkflows.length} orphaned workflows for user ${userId}`)
}
}