Skip to content

Commit cce2e10

Browse files
committed
fix(sidebar): prefetch folders and workspace permissions on cold load
1 parent 85f80fa commit cce2e10

5 files changed

Lines changed: 112 additions & 37 deletions

File tree

apps/sim/app/api/workspaces/[id]/permissions/route.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
1414
import { captureServerEvent } from '@/lib/posthog/server'
1515
import {
16-
checkWorkspaceAccess,
17-
getUserEntityPermissions,
1816
getUsersWithPermissions,
17+
getWorkspacePermissionsForViewer,
1918
hasWorkspaceAdminAccess,
20-
type PermissionType,
2119
} from '@/lib/workspaces/permissions/utils'
2220

2321
const logger = createLogger('WorkspacesPermissionsAPI')
@@ -41,37 +39,13 @@ export const GET = withRouteHandler(
4139
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
4240
}
4341

44-
const isAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
45-
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
42+
const result = await getWorkspacePermissionsForViewer(workspaceId, session.user.id)
4643

47-
if (!access.exists) {
44+
if (!result) {
4845
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
4946
}
5047

51-
if (!isAdmin && !access.hasAccess) {
52-
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
53-
}
54-
55-
const explicitPermission = await getUserEntityPermissions(
56-
session.user.id,
57-
'workspace',
58-
workspaceId
59-
)
60-
const viewerPermissionType: PermissionType = isAdmin
61-
? 'admin'
62-
: (explicitPermission ?? 'read')
63-
64-
const result = await getUsersWithPermissions(workspaceId)
65-
66-
return NextResponse.json({
67-
users: result,
68-
total: result.length,
69-
viewer: {
70-
userId: session.user.id,
71-
isAdmin,
72-
permissionType: viewerPermissionType,
73-
},
74-
})
48+
return NextResponse.json(result)
7549
} catch (error) {
7650
logger.error('Error fetching workspace permissions:', error)
7751
return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 })

apps/sim/app/workspace/[workspaceId]/prefetch.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import type { QueryClient } from '@tanstack/react-query'
22
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
3+
import { listFoldersForWorkspace } from '@/lib/folders/queries'
34
import { listWorkflowsForUser } from '@/lib/workflows/queries'
4-
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
5+
import {
6+
checkWorkspaceAccess,
7+
getWorkspacePermissionsForViewer,
8+
} from '@/lib/workspaces/permissions/utils'
9+
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
510
import {
611
MOTHERSHIP_CHAT_LIST_STALE_TIME,
712
mapChat,
813
mothershipChatKeys,
914
} from '@/hooks/queries/mothership-chats'
15+
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
1016
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
1117
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
18+
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
1219

1320
/** Resolves whether the user may access the workspace, swallowing errors to a `false`. */
1421
async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise<boolean> {
@@ -21,11 +28,12 @@ async function userCanAccessWorkspace(workspaceId: string, userId: string): Prom
2128
}
2229

2330
/**
24-
* Prefetches the sidebar's workflow + chat lists for a workspace and stores them
25-
* under the same query keys + mappers the client hooks use, so the persistent
26-
* sidebar paints populated on the first server render instead of flashing skeletons
27-
* on a cold load (e.g. after the browser discards an idle tab). Calls the data layer
28-
* directly — the same functions the API routes use — with no internal HTTP hop.
31+
* Prefetches the sidebar's workflow, chat, folder, and workspace-permissions lists for
32+
* a workspace and stores them under the same query keys + mappers the client hooks use,
33+
* so the persistent sidebar paints populated on the first server render instead of
34+
* flashing skeletons on a cold load (e.g. after the browser discards an idle tab). Calls
35+
* the data layer directly — the same functions the API routes use — with no internal
36+
* HTTP hop.
2937
*
3038
* Skips silently when the user can't access the workspace, leaving the client to
3139
* fetch and surface the real error instead of caching an empty list.
@@ -53,5 +61,22 @@ export async function prefetchWorkspaceSidebar(
5361
},
5462
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
5563
}),
64+
queryClient.prefetchQuery({
65+
queryKey: folderKeys.list(workspaceId, 'active'),
66+
queryFn: async () => {
67+
const rows = await listFoldersForWorkspace(workspaceId, 'active')
68+
return rows.map(mapFolder)
69+
},
70+
staleTime: FOLDER_LIST_STALE_TIME,
71+
}),
72+
queryClient.prefetchQuery({
73+
queryKey: workspaceKeys.permissions(workspaceId),
74+
queryFn: async () => {
75+
const result = await getWorkspacePermissionsForViewer(workspaceId, userId)
76+
if (!result) throw new Error('Workspace not found or access denied')
77+
return result
78+
},
79+
staleTime: WORKSPACE_PERMISSIONS_STALE_TIME,
80+
}),
5681
])
5782
}

apps/sim/hooks/queries/workspace.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const workspaceKeys = {
4242

4343
export type { Workspace, WorkspaceCreationPolicy, WorkspaceMember, WorkspacePermissions }
4444

45+
export const WORKSPACE_PERMISSIONS_STALE_TIME = 30 * 1000
46+
4547
async function fetchWorkspaces(
4648
scope: WorkspaceQueryScope = 'active',
4749
signal?: AbortSignal
@@ -250,7 +252,7 @@ export function useWorkspacePermissionsQuery(workspaceId: string | null | undefi
250252
queryKey: workspaceKeys.permissions(workspaceId ?? ''),
251253
queryFn: ({ signal }) => fetchWorkspacePermissions(workspaceId as string, signal),
252254
enabled: Boolean(workspaceId),
253-
staleTime: 30 * 1000,
255+
staleTime: WORKSPACE_PERMISSIONS_STALE_TIME,
254256
placeholderData: keepPreviousData,
255257
})
256258
}

apps/sim/lib/folders/queries.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { db } from '@sim/db'
2+
import { workflowFolder } from '@sim/db/schema'
3+
import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm'
4+
import type { FolderApi } from '@/lib/api/contracts/folders'
5+
import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys'
6+
7+
/** Normalizes timestamp columns to ISO strings to honor the `FolderApi` wire contract. */
8+
function toFolderApi(row: typeof workflowFolder.$inferSelect): FolderApi {
9+
return {
10+
...row,
11+
createdAt: row.createdAt.toISOString(),
12+
updatedAt: row.updatedAt.toISOString(),
13+
archivedAt: row.archivedAt ? row.archivedAt.toISOString() : null,
14+
}
15+
}
16+
17+
/** Mirrors the query in `app/api/folders/route.ts` GET handler for server-side (non-HTTP) callers. */
18+
export async function listFoldersForWorkspace(
19+
workspaceId: string,
20+
scope: FolderQueryScope
21+
): Promise<FolderApi[]> {
22+
const archivedFilter =
23+
scope === 'archived' ? isNotNull(workflowFolder.archivedAt) : isNull(workflowFolder.archivedAt)
24+
25+
const rows = await db
26+
.select()
27+
.from(workflowFolder)
28+
.where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter))
29+
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
30+
31+
return rows.map(toFolderApi)
32+
}

apps/sim/lib/workspaces/permissions/utils.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,48 @@ export async function getWorkspaceMemberProfiles(
376376
return rows
377377
}
378378

379+
export interface WorkspacePermissionsForViewer {
380+
users: WorkspaceMemberWithRole[]
381+
total: number
382+
viewer: {
383+
userId: string
384+
isAdmin: boolean
385+
permissionType: PermissionType
386+
}
387+
}
388+
389+
/**
390+
* Builds the workspace permissions payload for a viewer: the full member list plus
391+
* the viewer's own resolved permission. Returns `null` when the workspace doesn't
392+
* exist or the viewer lacks access, mirroring the 404 branch in the permissions
393+
* route. Shared by `GET /api/workspaces/[id]/permissions` and the sidebar prefetch
394+
* so the two never drift.
395+
*
396+
* @param workspaceId - The workspace ID to build permissions for
397+
* @param userId - The viewer's user ID
398+
*/
399+
export async function getWorkspacePermissionsForViewer(
400+
workspaceId: string,
401+
userId: string
402+
): Promise<WorkspacePermissionsForViewer | null> {
403+
const isAdmin = await hasWorkspaceAdminAccess(userId, workspaceId)
404+
const access = await checkWorkspaceAccess(workspaceId, userId)
405+
406+
if (!access.exists || (!isAdmin && !access.hasAccess)) {
407+
return null
408+
}
409+
410+
const explicitPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
411+
const viewerPermissionType: PermissionType = isAdmin ? 'admin' : (explicitPermission ?? 'read')
412+
const users = await getUsersWithPermissions(workspaceId)
413+
414+
return {
415+
users,
416+
total: users.length,
417+
viewer: { userId, isAdmin, permissionType: viewerPermissionType },
418+
}
419+
}
420+
379421
/**
380422
* Check if a user has admin access to a specific workspace
381423
*

0 commit comments

Comments
 (0)