From 515ef191529d5a78a5241bf2c231b9de46173abd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 01:27:26 -0700 Subject: [PATCH] improvement(sidebar): prefetch workspace list server-side so the switcher never flashes loading --- .../app/_shell/providers/session-provider.tsx | 3 +- apps/sim/app/api/workspaces/route.ts | 124 +++-------------- .../workspace/[workspaceId]/layout.test.tsx | 3 +- .../app/workspace/[workspaceId]/layout.tsx | 10 +- .../app/workspace/[workspaceId]/prefetch.ts | 49 +++++-- .../workspace-header/workspace-header.tsx | 18 ++- .../queries/utils/workspace-list-query.ts | 27 ++++ apps/sim/hooks/queries/workspace.ts | 37 +---- apps/sim/lib/auth/session-response.ts | 12 ++ apps/sim/lib/workspaces/list.ts | 128 ++++++++++++++++++ apps/sim/lib/workspaces/policy.ts | 32 ++++- 11 files changed, 285 insertions(+), 158 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/workspace-list-query.ts create mode 100644 apps/sim/lib/workspaces/list.ts diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 7c56fbf2e7b..22f110a0eb1 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -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' @@ -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 } diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 8d35dc7429b..e63aab34a02 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -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' @@ -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') @@ -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()) ) @@ -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 }) } @@ -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() - const planCategoryByOrg = new Map() - 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 @@ -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, @@ -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, @@ -401,9 +311,7 @@ async function createWorkspace({ updatedAt: now, role: 'owner', permissions: 'admin', - inviteMembersEnabled: invitePolicy.allowed, - inviteDisabledReason, - inviteUpgradeRequired: canActOnUpgrade, + ...resolveInviteFlags(invitePolicy, billedAccountUserId === userId), } } diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index c2bd9879894..b8f0399d751 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -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({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index f10df8f2883..a2cae048084 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -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, @@ -44,12 +45,19 @@ export default async function WorkspaceLayout({ return } + 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' diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 527905b9c22..e54318c55a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -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 { @@ -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, @@ -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 { if (hostContext.workspace.id !== workspaceId) return await Promise.all([ @@ -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') + } + // 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, + }), queryClient.prefetchQuery({ queryKey: workspaceKeys.permissions(workspaceId), queryFn: () => diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 3de09fe074e..a6e5df48a1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -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 @@ -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 @@ -418,8 +423,7 @@ function WorkspaceHeaderImpl({ <>
{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 diff --git a/apps/sim/hooks/queries/utils/workspace-list-query.ts b/apps/sim/hooks/queries/utils/workspace-list-query.ts new file mode 100644 index 00000000000..74617e39dcd --- /dev/null +++ b/apps/sim/hooks/queries/utils/workspace-list-query.ts @@ -0,0 +1,27 @@ +import type { Workspace, WorkspacesResponse } from '@/lib/api/contracts' + +export const WORKSPACE_LIST_STALE_TIME = 30 * 1000 + +/** + * Applies cached-shape defaults to a single schema-parsed wire workspace — + * only the invite fields are optional on the wire; everything else is + * guaranteed by the contract schema. + */ +export function normalizeWorkspace(workspace: Workspace): Workspace { + return { + ...workspace, + inviteMembersEnabled: workspace.inviteMembersEnabled ?? false, + inviteDisabledReason: workspace.inviteDisabledReason ?? null, + inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false, + } +} + +/** + * Normalizes the schema-parsed /api/workspaces payload into the cached shape. + * Shared by the client workspace-list fetch and the workspace layout's + * server-side sidebar prefetch so the two can never cache different shapes + * under `workspaceKeys.list`. + */ +export function normalizeWorkspacesResponse(data: WorkspacesResponse): WorkspacesResponse { + return { ...data, workspaces: data.workspaces.map(normalizeWorkspace) } +} diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 5b9831a3f4b..8dd00c49ebe 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -18,6 +18,11 @@ import { type WorkspaceQueryScope, type WorkspacesResponse, } from '@/lib/api/contracts' +import { + normalizeWorkspace, + normalizeWorkspacesResponse, + WORKSPACE_LIST_STALE_TIME, +} from '@/hooks/queries/utils/workspace-list-query' /** * Query key factory for workspace-related queries. @@ -40,7 +45,6 @@ export const workspaceKeys = { export type { Workspace, WorkspaceCreationPolicy, WorkspaceMember, WorkspacePermissions } export const WORKSPACE_PERMISSIONS_STALE_TIME = 30 * 1000 -export const WORKSPACE_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_SETTINGS_STALE_TIME = 30 * 1000 export const WORKSPACE_MEMBERS_STALE_TIME = 5 * 60 * 1000 export const WORKSPACE_ADMIN_LIST_STALE_TIME = 60 * 1000 @@ -50,27 +54,7 @@ async function fetchWorkspaces( signal?: AbortSignal ): Promise { const data = await requestJson(listWorkspacesContract, { query: { scope }, signal }) - return { - workspaces: - data.workspaces?.map((workspace: Workspace) => ({ - ...workspace, - organizationId: workspace.organizationId ?? null, - workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared', - inviteMembersEnabled: workspace.inviteMembersEnabled ?? false, - inviteDisabledReason: workspace.inviteDisabledReason ?? null, - inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false, - })) || [], - lastActiveWorkspaceId: - typeof data.lastActiveWorkspaceId === 'string' ? data.lastActiveWorkspaceId : null, - creationPolicy: data.creationPolicy - ? { - ...data.creationPolicy, - organizationId: data.creationPolicy.organizationId ?? null, - reason: data.creationPolicy.reason ?? null, - workspaceMode: data.creationPolicy.workspaceMode ?? 'personal', - } - : null, - } + return normalizeWorkspacesResponse(data) } const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces @@ -338,14 +322,7 @@ async function fetchAdminWorkspaces( } const workspacesData = await requestJson(listWorkspacesContract, { query: {}, signal }) - const allUserWorkspaces = (workspacesData.workspaces || []).map((workspace: Workspace) => ({ - ...workspace, - organizationId: workspace.organizationId ?? null, - workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared', - inviteMembersEnabled: workspace.inviteMembersEnabled ?? false, - inviteDisabledReason: workspace.inviteDisabledReason ?? null, - inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false, - })) + const allUserWorkspaces = workspacesData.workspaces.map(normalizeWorkspace) return allUserWorkspaces .filter((workspace: Workspace) => workspace.permissions === 'admin') diff --git a/apps/sim/lib/auth/session-response.ts b/apps/sim/lib/auth/session-response.ts index f78675ae178..1a450435f03 100644 --- a/apps/sim/lib/auth/session-response.ts +++ b/apps/sim/lib/auth/session-response.ts @@ -25,6 +25,18 @@ export type AppSession = { } } | null +/** + * Reads the organization plugin's `activeOrganizationId` off a session object + * (server `getSession()` result or client {@link AppSession}). Better Auth's + * inferred server session type does not declare the field, so this is the one + * place the untyped read happens. + */ +export function getActiveOrganizationId(session: unknown): string | null { + if (!isRecordLike(session) || !isRecordLike(session.session)) return null + const value = session.session.activeOrganizationId + return typeof value === 'string' ? value : null +} + interface BetterAuthErrorEnvelope { data: null error: { diff --git a/apps/sim/lib/workspaces/list.ts b/apps/sim/lib/workspaces/list.ts new file mode 100644 index 00000000000..0b2fd146fba --- /dev/null +++ b/apps/sim/lib/workspaces/list.ts @@ -0,0 +1,128 @@ +import { db } from '@sim/db' +import { settings, type workspace as workspaceTable } from '@sim/db/schema' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { eq } from 'drizzle-orm' +import type { PlanCategory } from '@/lib/billing/plan-helpers' +import { + evaluateWorkspaceInvitePolicy, + getInvitePlanCategoryForOrganization, + getInvitePlanCategoryForUser, + getWorkspaceCreationPolicy, + resolveInviteFlags, + WORKSPACE_MODE, + type WorkspaceCreationPolicy, + type WorkspaceInviteFlags, +} from '@/lib/workspaces/policy' +import { listAccessibleWorkspaceRowsForUser, type WorkspaceScope } from '@/lib/workspaces/utils' + +type WorkspaceRow = typeof workspaceTable.$inferSelect + +/** Accessible workspace row decorated with the viewer's role and invite policy flags. */ +export type WorkspaceWithInviteFlags = WorkspaceRow & + WorkspaceInviteFlags & { + role: 'owner' | 'admin' | 'member' + permissions: PermissionType + } + +/** The GET /api/workspaces payload assembled by {@link listWorkspacesForViewer}. */ +export interface WorkspaceListPayload { + workspaces: WorkspaceWithInviteFlags[] + lastActiveWorkspaceId: string | null + creationPolicy: WorkspaceCreationPolicy +} + +/** + * Decorates accessible workspace rows with the viewer's role and per-workspace + * invite policy flags (resolving each workspace's billed plan category once per + * billed user / organization). + */ +async function buildWorkspacesWithInviteFlags( + userWorkspaces: Array<{ workspace: WorkspaceRow; permissionType: PermissionType }>, + userId: string +): Promise { + 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() + const planCategoryByOrg = new Map() + await Promise.all([ + ...nonOrgBilledUserIds.map(async (billedUserId) => { + planCategoryByBilledUser.set(billedUserId, await getInvitePlanCategoryForUser(billedUserId)) + }), + ...orgIds.map(async (orgId) => { + planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId)) + }), + ]) + + return 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 }) + + return { + ...workspaceDetails, + role: + workspaceDetails.ownerId === userId + ? ('owner' as const) + : permissionType === 'admin' + ? ('admin' as const) + : ('member' as const), + permissions: permissionType, + ...resolveInviteFlags(invitePolicy, workspaceDetails.billedAccountUserId === userId), + } + }) +} + +/** + * Read-only assembly of the GET /api/workspaces payload for a viewer: accessible + * workspaces with role/invite flags, the viewer's last active workspace id, and + * the workspace creation policy. + * + * Unlike the route, this performs no writes — no default-workspace creation and + * no orphaned-workflow repair. It exists for the workspace layout's sidebar + * prefetch, which only runs after host-context authorization has proven the + * viewer already has at least one accessible workspace. + */ +export async function listWorkspacesForViewer(params: { + userId: string + activeOrganizationId: string | null + scope?: WorkspaceScope +}): Promise { + const { userId, activeOrganizationId, scope = 'active' } = params + + const [creationPolicy, workspaces, userSettings] = await Promise.all([ + getWorkspaceCreationPolicy({ userId, activeOrganizationId }), + listAccessibleWorkspaceRowsForUser(userId, scope).then((rows) => + buildWorkspacesWithInviteFlags(rows, userId) + ), + db + .select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1), + ]) + + return { + workspaces, + lastActiveWorkspaceId: userSettings[0]?.lastActiveWorkspaceId ?? null, + creationPolicy, + } +} diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index b6ec9c3ca13..5ad280e28b5 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -10,7 +10,10 @@ 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' +import { + CONTACT_OWNER_TO_UPGRADE_REASON, + UPGRADE_TO_INVITE_REASON, +} from '@/lib/workspaces/policy-constants' const logger = createLogger('WorkspacePolicy') @@ -40,6 +43,33 @@ export interface WorkspaceInvitePolicy { upgradeRequired: boolean } +/** Caller-facing invite flags derived from an evaluated invite policy. */ +export interface WorkspaceInviteFlags { + inviteMembersEnabled: boolean + inviteDisabledReason: string | null + inviteUpgradeRequired: boolean +} + +/** + * Derives the caller-facing invite flags for a workspace response. Only the + * billed user can act on an upgrade, so everyone else gets the contact-owner + * message when invites are disabled. + */ +export function resolveInviteFlags( + invitePolicy: WorkspaceInvitePolicy, + callerIsBilledUser: boolean +): WorkspaceInviteFlags { + return { + inviteMembersEnabled: invitePolicy.allowed, + inviteDisabledReason: invitePolicy.allowed + ? null + : callerIsBilledUser + ? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON) + : CONTACT_OWNER_TO_UPGRADE_REASON, + inviteUpgradeRequired: invitePolicy.upgradeRequired && callerIsBilledUser, + } +} + export interface WorkspaceCreationPolicy { canCreate: boolean workspaceMode: WorkspaceMode