Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/app/_shell/providers/session-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { client } from '@/lib/auth/auth-client'
import {
type AppSession,
extractSessionDataFromAuthClientResult,
getActiveOrganizationId,
} from '@/lib/auth/session-response'
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'

Expand Down Expand Up @@ -71,7 +72,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
queryClient.invalidateQueries({ queryKey: ['organizations'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] })

const activeOrganizationId = session?.session?.activeOrganizationId ?? null
const activeOrganizationId = getActiveOrganizationId(session)
if (!session || activeOrganizationId) {
return
}
Expand Down
124 changes: 16 additions & 108 deletions apps/sim/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
import { permissions, 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'
Expand All @@ -9,24 +9,20 @@ 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 { getActiveOrganizationId } from '@/lib/auth/session-response'
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 { listWorkspacesForViewer } from '@/lib/workspaces/list'
import {
CONTACT_OWNER_TO_UPGRADE_REASON,
evaluateWorkspaceInvitePolicy,
getInvitePlanCategoryForOrganization,
getInvitePlanCategoryForUser,
getWorkspaceCreationPolicy,
getWorkspaceInvitePolicy,
UPGRADE_TO_INVITE_REASON,
resolveInviteFlags,
WORKSPACE_MODE,
} from '@/lib/workspaces/policy'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'

const logger = createLogger('Workspaces')

Expand All @@ -38,13 +34,6 @@ export const GET = withRouteHandler(async (request: Request) => {
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())
)
Expand All @@ -56,20 +45,15 @@ export const GET = withRouteHandler(async (request: Request) => {
}
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
const activeOrganizationId = getActiveOrganizationId(session)
const payload = await listWorkspacesForViewer({
userId: session.user.id,
activeOrganizationId,
scope,
})
const { lastActiveWorkspaceId, creationPolicy } = payload

if (scope === 'active' && userWorkspaces.length === 0) {
if (scope === 'active' && payload.workspaces.length === 0) {
if (!creationPolicy.canCreate) {
return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy })
}
Expand All @@ -95,76 +79,10 @@ export const GET = withRouteHandler(async (request: Request) => {
}

if (scope === 'active') {
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
await ensureWorkflowsHaveWorkspace(session.user.id, payload.workspaces[0].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,
})
return NextResponse.json(payload)
})

// POST /api/workspaces - Create a new workspace
Expand All @@ -179,8 +97,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
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 activeOrganizationId = getActiveOrganizationId(session)
const creationPolicy = await getWorkspaceCreationPolicy({
userId: session.user.id,
activeOrganizationId,
Expand Down Expand Up @@ -380,13 +297,6 @@ async function createWorkspace({
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,
Expand All @@ -401,9 +311,7 @@ async function createWorkspace({
updatedAt: now,
role: 'owner',
permissions: 'admin',
inviteMembersEnabled: invitePolicy.allowed,
inviteDisabledReason,
inviteUpgradeRequired: canActOnUpgrade,
...resolveInviteFlags(invitePolicy, billedAccountUserId === userId),
}
}

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ describe('WorkspaceLayout host context', () => {
expect.anything(),
'workspace-b',
'viewer-1',
HOST_CONTEXT
HOST_CONTEXT,
'org-a'
)
expect(mockBrandingProvider).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import {
ImpersonationBanner,
Expand Down Expand Up @@ -44,12 +45,19 @@ export default async function WorkspaceLayout({
return <WorkspaceAccessDenied />
}

const activeOrganizationId = getActiveOrganizationId(session)
const [cookieStore, initialOrgSettings] = await Promise.all([
cookies(),
hostContext.hostOrganizationId
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
: Promise.resolve(null),
prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id, hostContext),
prefetchWorkspaceSidebar(
queryClient,
workspaceId,
session.user.id,
hostContext,
activeOrganizationId
),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

Expand Down
49 changes: 40 additions & 9 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { QueryClient } from '@tanstack/react-query'
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { listFoldersForWorkspace } from '@/lib/folders/queries'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
import {
Expand All @@ -14,6 +15,10 @@ import {
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
import {
normalizeWorkspacesResponse,
WORKSPACE_LIST_STALE_TIME,
} from '@/hooks/queries/utils/workspace-list-query'
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
import {
WORKSPACE_HOST_CONTEXT_STALE_TIME,
Expand All @@ -37,22 +42,27 @@ export function prefetchWorkspaceHostContext(
}

/**
* Prefetches the sidebar's workflow, chat, folder, and workspace-permissions lists for
* a workspace and stores them under the same query keys + mappers the client hooks use,
* so the persistent sidebar paints populated on the first server render instead of
* flashing skeletons on a cold load (e.g. after the browser discards an idle tab). Calls
* the data layer directly — the same functions the API routes use — with no internal
* HTTP hop.
* Prefetches the sidebar's workflow, chat, folder, workspace-permissions, and
* workspace lists for a workspace and stores them under the same query keys +
* mappers the client hooks use, so the persistent sidebar (including the
* workspace switcher header) paints populated on the first server render
* instead of flashing skeletons on a cold load (e.g. after the browser
* discards an idle tab). Calls the data layer directly — the same functions
* the API routes use — with no internal HTTP hop.
*
* The host context is the authorization proof for this server-render pass, so
* permission prefetch can reuse its effective permission without repeating
* workspace and membership reads.
* workspace and membership reads. It also proves the viewer has at least one
* accessible workspace, which is why the workspace-list prefetch can safely
* skip the route's empty-list default-workspace creation path — and the
* route's orphaned-workflow repair, which still runs on client refetches.
*/
export async function prefetchWorkspaceSidebar(
queryClient: QueryClient,
workspaceId: string,
userId: string,
hostContext: WorkspaceHostContext
hostContext: WorkspaceHostContext,
activeOrganizationId: string | null
): Promise<void> {
if (hostContext.workspace.id !== workspaceId) return
await Promise.all([
Expand Down Expand Up @@ -80,6 +90,27 @@ export async function prefetchWorkspaceSidebar(
},
staleTime: FOLDER_LIST_STALE_TIME,
}),
queryClient.prefetchQuery({
queryKey: workspaceKeys.list('active'),
queryFn: async () => {
const payload = await listWorkspacesForViewer({
userId,
activeOrganizationId,
scope: 'active',
})
// An empty list means GET /api/workspaces' default-workspace creation
// path must run — throw so prefetchQuery caches nothing and the client
// fetch reaches the route.
if (payload.workspaces.length === 0) {
throw new Error('Empty workspace list requires the route creation path')
}
Comment thread
waleedlatif1 marked this conversation as resolved.
// Parsing through the route contract's response schema strips the same
// server-only fields `requestJson` strips on the client, guaranteeing the
// cached shape is identical to a client fetch.
return normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload))
},
staleTime: WORKSPACE_LIST_STALE_TIME,
}),
Comment thread
waleedlatif1 marked this conversation as resolved.
queryClient.prefetchQuery({
queryKey: workspaceKeys.permissions(workspaceId),
queryFn: () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'

const logger = createLogger('WorkspaceHeader')

/**
* Derives the single-letter avatar initial for a workspace, ignoring the word
* "workspace" in the name (e.g. "Acme Workspace" → "A").
*/
function getWorkspaceInitial(name: string | undefined): string {
const stripped = (name ?? '').replace(/workspace/gi, '').trim()
return (stripped[0] || name?.[0] || 'W').toUpperCase()
}

interface DisabledReasonTooltipProps {
reason: string | null
children: ReactElement
Expand Down Expand Up @@ -182,11 +191,7 @@ function WorkspaceHeaderImpl({
}
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])

const workspaceInitial = (() => {
const name = activeWorkspace?.name || ''
const stripped = name.replace(/workspace/gi, '').trim()
return (stripped[0] || name[0] || 'W').toUpperCase()
})()
const workspaceInitial = getWorkspaceInitial(activeWorkspace?.name)

/**
* Opens the context menu for a workspace at the specified position
Expand Down Expand Up @@ -418,8 +423,7 @@ function WorkspaceHeaderImpl({
<>
<div className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'>
{workspaces.map((workspace) => {
const stripped = workspace.name.replace(/workspace/gi, '').trim()
const initial = (stripped[0] || workspace.name[0] || 'W').toUpperCase()
const initial = getWorkspaceInitial(workspace.name)
const isActive = workspace.id === workspaceId
const isMenuOpen = menuOpenWorkspaceId === workspace.id

Expand Down
Loading
Loading