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
451 lines
14 KiB
TypeScript
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}`)
|
|
}
|
|
}
|