diff --git a/apps/sim/app/api/invitations/route.ts b/apps/sim/app/api/invitations/route.ts index 106a7177799..e1bff103d2e 100644 --- a/apps/sim/app/api/invitations/route.ts +++ b/apps/sim/app/api/invitations/route.ts @@ -1,16 +1,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' -const logger = createLogger('MyInvitationsAPI') +const logger = createLogger('InvitationsAPI') /** * Pending invitations addressed to the session's email — the invitee-facing - * list behind the workspace switcher's Invitations section. Acceptance is - * session-bound (email match), so rows deliberately exclude the token. + * list behind the workspace switcher's Invitations section. */ export const GET = withRouteHandler(async () => { const session = await getSession() @@ -20,59 +18,8 @@ export const GET = withRouteHandler(async () => { } try { - const invitations = await listPendingInvitationsForEmail(session.user.email) - - /** - * Each row carries what accepting it will actually do, so the in-app list - * can disclose the workspace migration and echo `disclosedWorkspaceIds` on - * accept — the same consent contract the emailed `/invite` page honours. - * Disclosure-only, so a preview failure degrades to `null` (the client - * shows a generic notice) rather than hiding the invitation. - * - * Sequential on purpose: each preview issues several queries, and this - * endpoint is hit whenever the workspace switcher opens. Fanning them out - * with `Promise.all` would hold one pooled connection per pending - * invitation for the length of the slowest one. The list is a handful of - * rows, so the added latency is not worth the pool pressure. - */ - const previews: Array> | null> = [] - for (const inv of invitations) { - try { - previews.push(await getInvitationJoinPreview(session.user.id, inv)) - } catch (previewError) { - logger.warn('Failed to compute join preview for pending invitation', { - invitationId: inv.id, - error: previewError, - }) - previews.push(null) - } - } - - return NextResponse.json({ - invitations: invitations.map( - (inv, index) => - ({ - id: inv.id, - kind: inv.kind, - email: inv.email, - organizationId: inv.organizationId, - organizationName: inv.organizationName, - membershipIntent: inv.membershipIntent, - role: inv.role, - status: inv.status, - expiresAt: inv.expiresAt.toISOString(), - createdAt: inv.createdAt.toISOString(), - inviterName: inv.inviterName, - inviterEmail: inv.inviterEmail, - grants: inv.grants.map((grant) => ({ - workspaceId: grant.workspaceId, - workspaceName: grant.workspaceName, - permission: grant.permission, - })), - joinPreview: previews[index], - }) satisfies MyInvitation - ), - }) + const invitations = await listPendingInvitationsForViewer(session.user.id, session.user.email) + return NextResponse.json({ invitations }) } catch (error) { logger.error('Failed to list pending invitations', { error }) return NextResponse.json({ error: 'Failed to list invitations' }, { status: 500 }) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index b2f9177b49d..8822b730905 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -11,9 +11,9 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { listKnowledgeBasesForViewer } from '@/lib/knowledge/queries' import { createKnowledgeBase, - getKnowledgeBases, KnowledgeBaseConflictError, KnowledgeBaseFolderError, KnowledgeBasePermissionError, @@ -46,7 +46,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } const { workspaceId, scope } = query.data - const knowledgeBasesWithCounts = await getKnowledgeBases( + const knowledgeBasesWithCounts = await listKnowledgeBasesForViewer( session.user.id, workspaceId, scope as KnowledgeBaseScope diff --git a/apps/sim/app/api/pinned-items/route.ts b/apps/sim/app/api/pinned-items/route.ts index bf31285fb61..0f88773d864 100644 --- a/apps/sim/app/api/pinned-items/route.ts +++ b/apps/sim/app/api/pinned-items/route.ts @@ -2,40 +2,21 @@ import { db, pinnedItem } from '@sim/db' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPinnedItemContract, listPinnedItemsContract, type PinnedItemApi, - pinnedResourceTypeSchema, } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { filterToActiveResources, pinnableResourceExists } from '@/lib/pinned-items/resources' +import { listPinnedItemsForViewer } from '@/lib/pinned-items/queries' +import { pinnableResourceExists } from '@/lib/pinned-items/resources' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('PinnedItemsAPI') -/** - * Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does - * not recognise. - * - * `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can - * grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore - * read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE - * list down rather than the single row, so the unknown kind is skipped instead. - * - * `filterToActiveResources` already drops these as a side effect of not having a table to look - * them up in; this makes the guarantee explicit and compiler-checked at the wire boundary. - */ -function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null { - const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType) - if (!resourceType.success) return null - return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() } -} - /** Lists the session user's pinned items in a workspace, optionally filtered to one `resourceType`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -52,30 +33,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - const rows = await db - .select() - .from(pinnedItem) - .where( - and( - eq(pinnedItem.userId, session.user.id), - eq(pinnedItem.workspaceId, workspaceId), - /** - * A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise - * appear in this workspace's unscoped listing as a resource *inside* itself. - * It is read from the workspace-list payload instead, so it is excluded here - * rather than left for a future unscoped caller to mistake for a real resource. - */ - resourceType - ? eq(pinnedItem.resourceType, resourceType) - : ne(pinnedItem.resourceType, 'workspace') - ) - ) - - const activeRows = await filterToActiveResources(rows, workspaceId) - - const pinnedItems = activeRows - .map(toPinnedItemApi) - .filter((item): item is PinnedItemApi => item !== null) + const pinnedItems = await listPinnedItemsForViewer(session.user.id, workspaceId, resourceType) return NextResponse.json({ pinnedItems }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 02e122c9b43..0d59f2f8497 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -16,14 +16,10 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' -import { - FileConflictError, - listWorkspaceFiles, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' +import { FileConflictError, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -73,15 +69,11 @@ export const GET = withRouteHandler( } const { scope } = queryResult.data - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const shares = await getWorkspaceShares('file', workspaceId) - const filesWithShares = files.map((file) => ({ - ...file, - share: shares.get(file.id) ?? null, - })) + const filesWithShares = await listWorkspaceFilesWithShares(workspaceId, scope) - logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) + logger.info( + `[${requestId}] Listed ${filesWithShares.length} files for workspace ${workspaceId}` + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index 2ed876e4ba0..ba8d25ce7a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { Files } from './files' @@ -19,10 +20,12 @@ export const metadata: Metadata = { * `loading.tsx` covers the navigation/chunk-load transition the same way. */ export default async function FilesPage({ params }: { params: Promise<{ workspaceId: string }> }) { - const { workspaceId } = await params + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) const queryClient = getQueryClient() - await prefetchFilesBrowser(queryClient, workspaceId) + if (session?.user?.id) { + await prefetchFilesBrowser(queryClient, workspaceId, session.user.id) + } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index 5d94a6f0d7f..f8b25930873 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders' -import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { WORKSPACE_FILE_FOLDERS_STALE_TIME, @@ -17,37 +17,31 @@ import { * first frame: workspace files, file folders, and (via {@link prefetchResourceListChrome}) * the pinned ids that drive row order plus the members behind the Owner column — * under the same query keys their client hooks (`useWorkspaceFiles`, - * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints - * populated on first render. + * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints populated + * on first render. * - * Both payloads carry `Date` fields, so they go through their routes and cache - * the serialized wire shape — see {@link prefetchInternalJson}. + * Without workspace access nothing is cached, so the client fetch reaches the route and + * gets the real 403. */ export async function prefetchFilesBrowser( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: workspaceFilesKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await prefetchInternalJson( - `/api/workspaces/${workspaceId}/files?scope=active` - ) - return data.success ? data.files : [] - }, + queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'), staleTime: WORKSPACE_FILES_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: workspaceFileFolderKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await prefetchInternalJson<{ folders?: WorkspaceFileFolderApi[] }>( - `/api/workspaces/${workspaceId}/files/folders?scope=active` - ) - return data.folders ?? [] - }, + queryFn: () => listWorkspaceFileFolders(workspaceId, { scope: 'active' }), staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, }), - prefetchResourceListChrome(queryClient, workspaceId, 'file'), + prefetchResourceListChrome(queryClient, workspaceId, userId, 'file'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index b7a6cc4ea95..c5e94005f89 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -24,12 +24,13 @@ export default async function HomePage({ params }: { params: Promise<{ workspace } const queryClient = getQueryClient() - const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) - const session = await getSession() const userId = session?.user?.id - const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId) - await listsPrefetch + + const [tableViewsEnabled] = await Promise.all([ + resolveTableViewsEnabled(workspaceId, userId), + userId ? prefetchHomeLists(queryClient, workspaceId, userId) : Promise.resolve(), + ]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts index f08791c0bbd..78ac01dd71f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { FolderApi } from '@/lib/api/contracts' -import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { WORKSPACE_FILES_LIST_STALE_TIME, @@ -16,34 +16,30 @@ import { * The workflow list (`workflowKeys.list(ws, 'active')`) is already hydrated by * the workspace sidebar prefetch and is intentionally not repeated here. * - * Folders are fetched through the route and mapped with the same `mapFolder` - * the hook applies, matching its cached shape (string dates → `Date`). Files - * carry `Date` fields, so they go through the route and cache the serialized - * wire shape — see {@link prefetchInternalJson}. + * Folders are mapped with the same `mapFolder` the hook applies, and files go through the + * same `listWorkspaceFilesWithShares` the Files browser and the route use, so the hydrated + * entry matches a client fetch exactly. */ export async function prefetchHomeLists( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), queryFn: async () => { - const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=workflow` - ) - return (folders ?? []).map(mapFolder) + const folders = await listFoldersForWorkspace(workspaceId, 'active', 'workflow') + return folders.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: workspaceFilesKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await prefetchInternalJson( - `/api/workspaces/${workspaceId}/files?scope=active` - ) - return data.success ? data.files : [] - }, + queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'), staleTime: WORKSPACE_FILES_LIST_STALE_TIME, }), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index 402d437b4f0..2a5268e536e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' @@ -21,10 +22,12 @@ export default async function KnowledgePage({ }: { params: Promise<{ workspaceId: string }> }) { - const { workspaceId } = await params + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) const queryClient = getQueryClient() - await prefetchKnowledgeBases(queryClient, workspaceId) + if (session?.user?.id) { + await prefetchKnowledgeBases(queryClient, workspaceId, session.user.id) + } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 7c9d45cb668..7b69c12edea 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { FolderApi } from '@/lib/api/contracts/folders' -import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listKnowledgeBasesForViewer } from '@/lib/knowledge/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -16,35 +16,31 @@ import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/u * beside, so prefetching one without the other still flashes an ungrouped list — and a * `?folderId=` deep link renders an empty breadcrumb until the folders arrive. * - * The list carries `Date` fields, so it goes through the `/api/knowledge` route and caches the - * serialized wire shape — see {@link prefetchInternalJson}. Folders are mapped with the same - * `mapFolder` the hook applies, so the hydrated entry matches a client fetch exactly. + * Folders are mapped with the same `mapFolder` the hook applies, so both hydrated entries + * match a client fetch exactly. */ export async function prefetchKnowledgeBases( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: knowledgeKeys.list(workspaceId, 'active'), - queryFn: async () => { - const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( - `/api/knowledge?workspaceId=${workspaceId}&scope=active` - ) - return result.data - }, + queryFn: () => listKnowledgeBasesForViewer(userId, workspaceId, 'active'), staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'), queryFn: async () => { - const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=knowledge_base` - ) - return (folders ?? []).map(mapFolder) + const folders = await listFoldersForWorkspace(workspaceId, 'active', 'knowledge_base') + return folders.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, }), - prefetchResourceListChrome(queryClient, workspaceId, 'knowledge_base'), + prefetchResourceListChrome(queryClient, workspaceId, userId, 'knowledge_base'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 26305f13a74..6f4bf7f3fd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -163,7 +163,8 @@ describe('WorkspaceLayout host context', () => { 'workspace-b', 'viewer-1', HOST_CONTEXT, - 'org-a' + 'org-a', + null ) expect(mockBrandingProvider).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index eb58536782e..a1393cbc702 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -55,7 +55,8 @@ export default async function WorkspaceLayout({ workspaceId, session.user.id, hostContext, - activeOrganizationId + activeOrganizationId, + session.user.email ?? null ), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts index e48f6064c17..1d5b4d3eb02 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -1,16 +1,19 @@ +import { createLogger } from '@sim/logger' import { headers } from 'next/headers' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +const logger = createLogger('PrefetchInternalFetch') + /** - * Server-side GET against an internal `/api` route, forwarding the incoming - * request's cookie so the route authenticates as the current user. + * Server-side GET against an internal `/api` route, forwarding the incoming request's cookie + * so the route authenticates as the current user. + * + * Only the tables list still needs this. `lib/table/service` transitively imports the + * executor, so reading `listTables` from a `page.tsx` pulls the tool registry into that + * route's graph and `bun run check:tool-registry-boundary` rejects it. * - * List prefetches go through the route (rather than the data layer) when the - * payload carries `Date` fields: `NextResponse.json` serializes them to the - * string wire shape the client caches via `requestJson`, so the - * server-hydrated entry byte-matches the client-fetched one through - * dehydration. Calling the data layer directly would cache raw `Date` objects - * and drift from that wire shape. Mirrors the settings/subscription prefetch. + * Failures are logged: `prefetchQuery` swallows the rejection and `shouldDehydrateQuery` + * drops the errored entry, so without this a failure silently ships a page missing that list. */ export async function prefetchInternalJson(path: string): Promise { const cookie = (await headers()).get('cookie') @@ -19,6 +22,10 @@ export async function prefetchInternalJson(path: string): Promise { headers: cookie ? { cookie } : {}, }) if (!response.ok) { + logger.error('Prefetch request failed; the list will client-fetch instead', { + path, + status: response.status, + }) throw new Error(`Prefetch failed for ${path}: ${response.status}`) } return response.json() as Promise diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts index 5d9241aa23f..5499595989c 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts @@ -1,12 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' -import type { PinnedItemApi, PinnedResourceType } from '@/lib/api/contracts/pinned-items' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import type { PinnedResourceType } from '@/lib/api/contracts/pinned-items' +import { listPinnedItemsForViewer } from '@/lib/pinned-items/queries' +import { getWorkspaceMemberProfiles } from '@/lib/workspaces/permissions/utils' import { PINNED_ITEMS_STALE_TIME, pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys' -import { - WORKSPACE_MEMBERS_STALE_TIME, - type WorkspaceMember, - workspaceKeys, -} from '@/hooks/queries/workspace' +import { WORKSPACE_MEMBERS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' /** * Prefetches the two lists every foldered resource page needs to paint a row completely, @@ -19,21 +16,19 @@ import { * * Members back the Owner column; without them every owner cell paints empty and fills in * after. Both are cheap and shared with the page's own list prefetch in one `Promise.all`. + * + * The caller has already authorized the viewer against `workspaceId`. */ export async function prefetchResourceListChrome( queryClient: QueryClient, workspaceId: string, + userId: string, resourceType: PinnedResourceType ): Promise { const prefetchPinned = (type: PinnedResourceType) => queryClient.prefetchQuery({ queryKey: pinnedItemKeys.list(workspaceId, type), - queryFn: async () => { - const { pinnedItems } = await prefetchInternalJson<{ pinnedItems: PinnedItemApi[] }>( - `/api/pinned-items?workspaceId=${workspaceId}&resourceType=${type}` - ) - return pinnedItems - }, + queryFn: () => listPinnedItemsForViewer(userId, workspaceId, type), staleTime: PINNED_ITEMS_STALE_TIME, }) @@ -42,12 +37,7 @@ export async function prefetchResourceListChrome( prefetchPinned('folder'), queryClient.prefetchQuery({ queryKey: workspaceKeys.members(workspaceId), - queryFn: async () => { - const { members } = await prefetchInternalJson<{ members: WorkspaceMember[] }>( - `/api/workspaces/${workspaceId}/members` - ) - return members - }, + queryFn: () => getWorkspaceMemberProfiles(workspaceId), staleTime: WORKSPACE_MEMBERS_STALE_TIME, }), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 392202a2224..793462dd6e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -4,13 +4,46 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPrefetchInternalJson } = vi.hoisted(() => ({ +const { + mockListKnowledgeBasesForViewer, + mockGetUserEntityPermissions, + mockGetWorkspaceMemberProfiles, + mockListFoldersForWorkspace, + mockListPinnedItemsForViewer, + mockPrefetchInternalJson, + mockListWorkspaceFileFolders, + mockListWorkspaceFilesWithShares, +} = vi.hoisted(() => ({ + mockListKnowledgeBasesForViewer: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceMemberProfiles: vi.fn(), + mockListFoldersForWorkspace: vi.fn(), + mockListPinnedItemsForViewer: vi.fn(), mockPrefetchInternalJson: vi.fn(), + mockListWorkspaceFileFolders: vi.fn(), + mockListWorkspaceFilesWithShares: vi.fn(), })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, + getWorkspaceMemberProfiles: mockGetWorkspaceMemberProfiles, +})) +vi.mock('@/lib/pinned-items/queries', () => ({ + listPinnedItemsForViewer: mockListPinnedItemsForViewer, +})) +vi.mock('@/lib/folders/queries', () => ({ listFoldersForWorkspace: mockListFoldersForWorkspace })) +vi.mock('@/lib/workspace-files/queries', () => ({ + listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFileFolders: mockListWorkspaceFileFolders, +})) vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ prefetchInternalJson: mockPrefetchInternalJson, })) +vi.mock('@/lib/knowledge/queries', () => ({ + listKnowledgeBasesForViewer: mockListKnowledgeBasesForViewer, +})) vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: vi.fn() }, @@ -29,6 +62,7 @@ import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' const WORKSPACE_ID = 'ws-123' +const USER_ID = 'user-1' function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -37,30 +71,36 @@ function makeClient() { describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockListPinnedItemsForViewer.mockResolvedValue([]) + mockGetWorkspaceMemberProfiles.mockResolvedValue([]) + mockListFoldersForWorkspace.mockResolvedValue([]) + mockListWorkspaceFilesWithShares.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) + mockPrefetchInternalJson.mockResolvedValue({ data: { tables: [] } }) + mockListKnowledgeBasesForViewer.mockResolvedValue([]) }) describe('prefetchKnowledgeBases', () => { - it('primes the exact key useKnowledgeBasesQuery reads and unwraps data', async () => { + it('primes the exact key useKnowledgeBasesQuery reads, scoped to the viewer', async () => { const bases = [{ id: 'kb-1' }] - mockPrefetchInternalJson.mockResolvedValue({ data: bases }) + mockListKnowledgeBasesForViewer.mockResolvedValue(bases) const client = makeClient() - await prefetchKnowledgeBases(client, WORKSPACE_ID) + await prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active` - ) + expect(mockListKnowledgeBasesForViewer).toHaveBeenCalledWith(USER_ID, WORKSPACE_ID, 'active') expect(client.getQueryData(knowledgeKeys.list(WORKSPACE_ID, 'active'))).toEqual(bases) }) }) describe('prefetchTables', () => { - it('primes the exact key useTablesList reads and unwraps data.tables', async () => { + it('primes the exact key useTablesList reads', async () => { const tables = [{ id: 't-1' }] mockPrefetchInternalJson.mockResolvedValue({ data: { tables } }) const client = makeClient() - await prefetchTables(client, WORKSPACE_ID) + await prefetchTables(client, WORKSPACE_ID, USER_ID) expect(mockPrefetchInternalJson).toHaveBeenCalledWith( `/api/table?workspaceId=${WORKSPACE_ID}&scope=active` @@ -73,37 +113,81 @@ describe('workspace list prefetches', () => { it('primes both file + folder keys the client hooks read', async () => { const files = [{ id: 'f-1' }] const folders = [{ id: 'folder-1' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.includes('/folders') ? { folders } : { success: true, files } - ) + mockListWorkspaceFilesWithShares.mockResolvedValue(files) + mockListWorkspaceFileFolders.mockResolvedValue(folders) const client = makeClient() - await prefetchFilesBrowser(client, WORKSPACE_ID) + await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/files?scope=active` - ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/files/folders?scope=active` - ) + expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active') + expect(mockListWorkspaceFileFolders).toHaveBeenCalledWith(WORKSPACE_ID, { scope: 'active' }) expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( folders ) }) - it('caches an empty file list when the route reports failure', async () => { - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.includes('/folders') ? { folders: [] } : { success: false, files: [] } + /** + * `prefetchQuery` swallows a rejection and `shouldDehydrateQuery` drops the errored + * entry, so one failing read can silently ship a page with that list missing. + */ + it('still primes folders when the files read throws', async () => { + const folders = [{ id: 'folder-1' }] + mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('files read failed')) + mockListWorkspaceFileFolders.mockResolvedValue(folders) + const client = makeClient() + + await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) + + expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( + folders ) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() + }) + }) + + describe('prefetchHomeLists', () => { + it('primes the workflow folder tree and the file list', async () => { + const files = [{ id: 'f-1' }] + mockListWorkspaceFilesWithShares.mockResolvedValue(files) const client = makeClient() - await prefetchFilesBrowser(client, WORKSPACE_ID) + await prefetchHomeLists(client, WORKSPACE_ID, USER_ID) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual([]) + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith(WORKSPACE_ID, 'active', 'workflow') + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) }) }) + describe('authorization', () => { + /** + * The prefetches call the data layer directly, bypassing the routes that used to + * authorize each read. A viewer without workspace access must therefore prime nothing — + * the client fetch then reaches the route and gets the real 403. + */ + const allPrefetches = [ + { name: 'files', run: prefetchFilesBrowser }, + { name: 'tables', run: prefetchTables }, + { name: 'knowledge', run: prefetchKnowledgeBases }, + { name: 'home', run: prefetchHomeLists }, + ] + + for (const { name, run } of allPrefetches) { + it(`caches nothing for ${name} when the viewer has no workspace access`, async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + const client = makeClient() + + await run(client, WORKSPACE_ID, USER_ID) + + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() + expect(mockPrefetchInternalJson).not.toHaveBeenCalled() + expect(mockListKnowledgeBasesForViewer).not.toHaveBeenCalled() + expect(mockListPinnedItemsForViewer).not.toHaveBeenCalled() + }) + } + }) + describe('resource-list chrome', () => { /** * Pinned ids are the list's primary sort key, so a page that paints without them renders @@ -120,25 +204,19 @@ describe('workspace list prefetches', () => { it(`primes pinned ids (${resourceType} + folder) and members for ${name}`, async () => { const pinnedItems = [{ id: 'p-1', resourceId: 'r-1' }] const members = [{ userId: 'u-1', name: 'Ada' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => { - if (path.startsWith('/api/pinned-items')) return { pinnedItems } - if (path.endsWith('/members')) return { members } - if (path.includes('/folders')) return { folders: [] } - return { success: true, files: [], data: { tables: [] } } - }) + mockListPinnedItemsForViewer.mockResolvedValue(pinnedItems) + mockGetWorkspaceMemberProfiles.mockResolvedValue(members) const client = makeClient() - await run(client, WORKSPACE_ID) + await run(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=${resourceType}` - ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=folder` - ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/members` + expect(mockListPinnedItemsForViewer).toHaveBeenCalledWith( + USER_ID, + WORKSPACE_ID, + resourceType ) + expect(mockListPinnedItemsForViewer).toHaveBeenCalledWith(USER_ID, WORKSPACE_ID, 'folder') + expect(mockGetWorkspaceMemberProfiles).toHaveBeenCalledWith(WORKSPACE_ID) expect(client.getQueryData(pinnedItemKeys.list(WORKSPACE_ID, resourceType))).toEqual( pinnedItems ) @@ -150,68 +228,28 @@ describe('workspace list prefetches', () => { } }) - describe('prefetchHomeLists', () => { - it('primes folder + file keys, mapping folder rows to the client shape', async () => { - const folderRow = { - id: 'folder-1', - name: 'Docs', - userId: 'u-1', - workspaceId: WORKSPACE_ID, - parentId: null, - resourceType: 'workflow', - locked: false, - sortOrder: 0, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - deletedAt: null, - } - const files = [{ id: 'f-1' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } - ) - const client = makeClient() - - await prefetchHomeLists(client, WORKSPACE_ID) - - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/folders?workspaceId=${WORKSPACE_ID}&scope=active&resourceType=workflow` - ) - const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{ - id: string - resourceType: string - createdAt: Date - }> - expect(cachedFolders).toHaveLength(1) - expect(cachedFolders[0].resourceType).toBe('workflow') - // The wire shape carries ISO strings; the client shape carries Dates. - expect(cachedFolders[0].createdAt).toBeInstanceOf(Date) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) - }) - }) + describe('folder trees', () => { + const folderCases = [ + { name: 'tables', run: prefetchTables, resourceType: 'table' as const }, + { name: 'knowledge', run: prefetchKnowledgeBases, resourceType: 'knowledge_base' as const }, + ] - describe('graceful failure', () => { - it.each([ - [ - 'prefetchKnowledgeBases', - prefetchKnowledgeBases, - knowledgeKeys.list(WORKSPACE_ID, 'active'), - ], - ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], - ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], - [ - 'prefetchFilesBrowser', - prefetchFilesBrowser, - workspaceFilesKeys.list(WORKSPACE_ID, 'active'), - ], - ] as const)( - '%s does not throw when the fetcher rejects (page still renders, client refetches)', - async (_name, prefetch, queryKey) => { - mockPrefetchInternalJson.mockRejectedValue(new Error('500')) + for (const { name, run, resourceType } of folderCases) { + it(`primes the ${name} folder tree under its own resourceType key`, async () => { + mockListFoldersForWorkspace.mockResolvedValue([]) const client = makeClient() - await expect(prefetch(client, WORKSPACE_ID)).resolves.toBeUndefined() - expect(client.getQueryData(queryKey)).toBeUndefined() - } - ) + await run(client, WORKSPACE_ID, USER_ID) + + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + 'active', + resourceType + ) + expect(client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active', resourceType))).toEqual( + [] + ) + }) + } }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index fe69e488fae..395d8405694 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -3,6 +3,7 @@ import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/con import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' import { getUserProfile } from '@/lib/users/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -19,6 +20,10 @@ import { userProfileKeys, } from '@/hooks/queries/user-profile' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' +import { + invitationKeys, + VIEWER_INVITATIONS_STALE_TIME, +} from '@/hooks/queries/utils/invitation-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { @@ -49,7 +54,7 @@ export function prefetchWorkspaceHostContext( /** * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, - * workspace, and viewer-profile reads for a workspace and stores them under the + * workspace, viewer-profile, and pending-invitation reads 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 and the footer's profile row) paints * populated on the first server render @@ -69,7 +74,8 @@ export async function prefetchWorkspaceSidebar( workspaceId: string, userId: string, hostContext: WorkspaceHostContext, - activeOrganizationId: string | null + activeOrganizationId: string | null, + userEmail: string | null ): Promise { if (hostContext.workspace.id !== workspaceId) return await Promise.all([ @@ -148,5 +154,19 @@ export async function prefetchWorkspaceSidebar( }, staleTime: USER_PROFILE_STALE_TIME, }), + /** + * The switcher's "View invitations" entry renders only when the viewer has pending + * invitations, and it mounts inside the dropdown — fetching on open made the entry + * appear a beat after the menu did. + */ + ...(userEmail + ? [ + queryClient.prefetchQuery({ + queryKey: invitationKeys.viewer(), + queryFn: () => listPendingInvitationsForViewer(userId, userEmail), + staleTime: VIEWER_INVITATIONS_STALE_TIME, + }), + ] + : []), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx index 0e9390a5d95..b5430eb2aca 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import TablesLoading from '@/app/workspace/[workspaceId]/tables/loading' import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' @@ -17,10 +18,12 @@ export const metadata: Metadata = { * route-level `loading.tsx` covers the navigation/chunk-load transition. */ export default async function TablesPage({ params }: { params: Promise<{ workspaceId: string }> }) { - const { workspaceId } = await params + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) const queryClient = getQueryClient() - await prefetchTables(queryClient, workspaceId) + if (session?.user?.id) { + await prefetchTables(queryClient, workspaceId, session.user.id) + } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 5a548885511..a232e085ee3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { FolderApi } from '@/lib/api/contracts/folders' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import type { TableDefinition } from '@/lib/table' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' @@ -14,12 +15,18 @@ import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-ke * only placed correctly relative to the folder rows it sits beside, so * prefetching one without the other still flashes an ungrouped list. * - * Table definitions carry `Date` fields, so the list goes through the - * `/api/table` route and caches the serialized wire shape — see - * {@link prefetchInternalJson}. Folders are mapped with the same `mapFolder` the - * hook applies so the hydrated entry matches a client fetch exactly. + * The tables list goes through its route rather than the data layer — see + * {@link prefetchInternalJson}. Folders are mapped with the same `mapFolder` the hook + * applies, so that entry matches a client fetch exactly. */ -export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise { +export async function prefetchTables( + queryClient: QueryClient, + workspaceId: string, + userId: string +): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: tableKeys.list(workspaceId, 'active'), @@ -34,13 +41,11 @@ export async function prefetchTables(queryClient: QueryClient, workspaceId: stri queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'table'), queryFn: async () => { - const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=table` - ) - return (folders ?? []).map(mapFolder) + const folders = await listFoldersForWorkspace(workspaceId, 'active', 'table') + return folders.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, }), - prefetchResourceListChrome(queryClient, workspaceId, 'table'), + prefetchResourceListChrome(queryClient, workspaceId, userId, 'table'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx index c0d1e12e5c2..bcac647c6b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx @@ -2,7 +2,7 @@ import { Chip } from '@sim/emcn' import { Mail } from '@sim/emcn/icons' -import { useMyPendingInvitations } from '@/hooks/queries/invitations' +import { usePendingInvitationsForViewer } from '@/hooks/queries/invitations' interface ViewInvitationsMenuItemProps { /** Close the workspace menu and open the invitations modal. */ @@ -11,11 +11,10 @@ interface ViewInvitationsMenuItemProps { /** * "View invitations" entry in the workspace switcher — rendered only when the - * signed-in account has pending invitations. Mounted inside the dropdown - * content, so the check runs when the menu opens (cached between opens). + * signed-in account has pending invitations. */ export function ViewInvitationsMenuItem({ onOpen }: ViewInvitationsMenuItemProps) { - const { data: invitations } = useMyPendingInvitations() + const { data: invitations } = usePendingInvitationsForViewer() if (!invitations || invitations.length === 0) { return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx index 541db1b29e5..16366f2a16c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -4,12 +4,12 @@ import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' -import type { MyInvitation } from '@/lib/api/contracts/invitations' +import type { ViewerInvitation } from '@/lib/api/contracts/invitations' import { getInvitationErrorMessage } from '@/lib/invitations/error-messages' import { useAcceptMyInvitation, useDeclineMyInvitation, - useMyPendingInvitations, + usePendingInvitationsForViewer, } from '@/hooks/queries/invitations' const logger = createLogger('ViewInvitationsModal') @@ -19,7 +19,7 @@ const logger = createLogger('ViewInvitationsModal') * invites are labeled by the org (even when workspace grants ride along); * workspace invites by their workspace(s). */ -function invitationLabel(inv: MyInvitation): string { +function invitationLabel(inv: ViewerInvitation): string { if (inv.kind === 'organization') { return inv.organizationName ?? 'Organization' } @@ -32,7 +32,7 @@ function invitationLabel(inv: MyInvitation): string { } /** Secondary line: who invited, plus role (org) or permission (workspace). */ -function invitationSubLabel(inv: MyInvitation): string { +function invitationSubLabel(inv: ViewerInvitation): string { const invitedBy = inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited' const detail = inv.kind === 'organization' ? inv.role : inv.grants[0]?.permission return detail ? `${invitedBy} · ${detail}` : invitedBy @@ -51,14 +51,14 @@ interface ViewInvitationsModalProps { * the joined workspace; declining keeps it open for the remaining rows. */ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModalProps) { - const { data: invitations } = useMyPendingInvitations(open) + const { data: invitations } = usePendingInvitationsForViewer() const acceptInvitation = useAcceptMyInvitation() const declineInvitation = useDeclineMyInvitation() const router = useRouter() const isBusy = acceptInvitation.isPending || declineInvitation.isPending - const handleAccept = async (inv: MyInvitation) => { + const handleAccept = async (inv: ViewerInvitation) => { try { const result = await acceptInvitation.mutateAsync({ invitationId: inv.id, @@ -79,7 +79,7 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa } } - const handleDecline = async (inv: MyInvitation) => { + const handleDecline = async (inv: ViewerInvitation) => { try { await declineInvitation.mutateAsync({ invitationId: inv.id }) } catch (error) { 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 82db252aea0..773e4f92520 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 @@ -30,7 +30,7 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/ import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' import { ViewInvitationsMenuItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item' import { ViewInvitationsModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal' -import { invitationKeys } from '@/hooks/queries/invitations' +import { invitationKeys } from '@/hooks/queries/utils/invitation-keys' import { type Workspace, type WorkspaceCreationPolicy, @@ -489,7 +489,7 @@ function WorkspaceHeaderImpl({ // or a fresh pending invitation, appears without a page refresh // (these are app-wide queries with no focus refetch on the web). void queryClient.refetchQueries({ queryKey: workspaceKeys.lists(), stale: true }) - void queryClient.refetchQueries({ queryKey: invitationKeys.mine(), stale: true }) + void queryClient.refetchQueries({ queryKey: invitationKeys.viewer(), stale: true }) } setIsWorkspaceMenuOpen(open) if (open && showSearch) { diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 394ff0b9113..d3f68928f68 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -11,41 +11,27 @@ import { cancelInvitationContract, getInvitationContract, type InvitationJoinOutcome, - listMyInvitationsContract, + listViewerInvitationsContract, listWorkspaceInvitationsContract, - type MyInvitation, type PendingInvitationRow, rejectInvitationContract, removeWorkspaceMemberContract, resendInvitationContract, + type ViewerInvitation, } from '@/lib/api/contracts/invitations' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { organizationKeys } from '@/hooks/queries/organization' import { refreshSessionQuery } from '@/hooks/queries/session' import { subscriptionKeys } from '@/hooks/queries/subscription' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + INVITATION_DETAILS_STALE_TIME, + invitationKeys, + VIEWER_INVITATIONS_STALE_TIME, + WORKSPACE_INVITATION_LIST_STALE_TIME, +} from '@/hooks/queries/utils/invitation-keys' import { workspaceKeys } from '@/hooks/queries/workspace' -export const invitationKeys = { - all: ['invitations'] as const, - lists: () => [...invitationKeys.all, 'list'] as const, - list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, - details: () => [...invitationKeys.all, 'detail'] as const, - /** - * Scoped by viewer: the response is viewer-dependent (the join preview is - * invitee-only, and authorization differs per account), so a cached entry - * must never be reused across a sign-out/sign-in on the same invite link — - * doing so would let a stale "nothing moves" preview become the disclosure - * basis for a different user. - */ - detail: (invitationId: string, token: string | null, viewerId: string | null) => - [...invitationKeys.details(), invitationId, token ?? '', viewerId ?? ''] as const, - mine: () => [...invitationKeys.all, 'mine'] as const, -} - -export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 -export const INVITATION_DETAILS_STALE_TIME = 30 * 1000 - async function fetchInvitationDetails( invitationId: string, token: string | null, @@ -123,25 +109,22 @@ export function usePendingInvitations(workspaceId: string | undefined) { }) } -export const MY_INVITATIONS_STALE_TIME = 30 * 1000 - -async function fetchMyPendingInvitations(signal?: AbortSignal): Promise { - const data = await requestJson(listMyInvitationsContract, { signal }) +async function fetchPendingInvitationsForViewer(signal?: AbortSignal): Promise { + const data = await requestJson(listViewerInvitationsContract, { signal }) return data.invitations } /** * Pending invitations addressed to the signed-in account, for the workspace - * switcher's Invitations section. The switcher menu-item mounts this on - * dropdown open (so it fetches then); the modal passes `enabled: open` so it - * does not fetch on every app load for the majority of users who have none. + * switcher's Invitations section. Hydrated by the sidebar's server prefetch, so + * the switcher's "View invitations" entry is present the moment the menu opens + * rather than appearing a beat later; opening the menu only revalidates. */ -export function useMyPendingInvitations(enabled = true) { +export function usePendingInvitationsForViewer() { return useQuery({ - queryKey: invitationKeys.mine(), - queryFn: ({ signal }) => fetchMyPendingInvitations(signal), - enabled, - staleTime: MY_INVITATIONS_STALE_TIME, + queryKey: invitationKeys.viewer(), + queryFn: ({ signal }) => fetchPendingInvitationsForViewer(signal), + staleTime: VIEWER_INVITATIONS_STALE_TIME, }) } @@ -191,7 +174,7 @@ export function useAcceptMyInvitation() { // (expired / already-processed since the list loaded) drops instead of // lingering as a re-clickable dead row. onSettled: () => { - queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + queryClient.invalidateQueries({ queryKey: invitationKeys.viewer() }) }, }) } @@ -204,7 +187,7 @@ export function useDeclineMyInvitation() { mutationFn: async ({ invitationId }: { invitationId: string }) => requestJson(rejectInvitationContract, { params: { id: invitationId }, body: {} }), onSettled: () => { - queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + queryClient.invalidateQueries({ queryKey: invitationKeys.viewer() }) }, }) } diff --git a/apps/sim/hooks/queries/utils/invitation-keys.ts b/apps/sim/hooks/queries/utils/invitation-keys.ts new file mode 100644 index 00000000000..666e567038b --- /dev/null +++ b/apps/sim/hooks/queries/utils/invitation-keys.ts @@ -0,0 +1,27 @@ +/** + * Lives in this standalone module — like {@link file://./pinned-item-keys.ts} and + * {@link file://./folder-keys.ts} — so the sidebar's server prefetch can hydrate the + * viewer's pending invitations without importing `@/hooks/queries/invitations`, which + * pulls the emcn toast surface and the whole invitation-mutation machinery in with it. + */ + +export const invitationKeys = { + all: ['invitations'] as const, + lists: () => [...invitationKeys.all, 'list'] as const, + list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, + details: () => [...invitationKeys.all, 'detail'] as const, + /** + * Scoped by viewer: the response is viewer-dependent (the join preview is + * invitee-only, and authorization differs per account), so a cached entry + * must never be reused across a sign-out/sign-in on the same invite link — + * doing so would let a stale "nothing moves" preview become the disclosure + * basis for a different user. + */ + detail: (invitationId: string, token: string | null, viewerId: string | null) => + [...invitationKeys.details(), invitationId, token ?? '', viewerId ?? ''] as const, + viewer: () => [...invitationKeys.all, 'viewer'] as const, +} + +export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 +export const INVITATION_DETAILS_STALE_TIME = 30 * 1000 +export const VIEWER_INVITATIONS_STALE_TIME = 30 * 1000 diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index a3a977e11cd..b7245398f44 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -242,19 +242,19 @@ export const getInvitationContract = defineRouteContract({ * when the preview could not be computed; the client then shows the generic * notice rather than treating it as "nothing moves". */ -export const myInvitationSchema = invitationDetailsSchema.extend({ +export const viewerInvitationSchema = invitationDetailsSchema.extend({ joinPreview: invitationJoinPreviewSchema.nullable(), }) -export type MyInvitation = z.output +export type ViewerInvitation = z.output -export const listMyInvitationsContract = defineRouteContract({ +export const listViewerInvitationsContract = defineRouteContract({ method: 'GET', path: '/api/invitations', response: { mode: 'json', schema: z.object({ - invitations: z.array(myInvitationSchema), + invitations: z.array(viewerInvitationSchema), }), }, }) diff --git a/apps/sim/lib/invitations/pending.test.ts b/apps/sim/lib/invitations/pending.test.ts new file mode 100644 index 00000000000..3c707762774 --- /dev/null +++ b/apps/sim/lib/invitations/pending.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetInvitationJoinPreview, mockListPendingInvitationsForEmail } = vi.hoisted(() => ({ + mockGetInvitationJoinPreview: vi.fn(), + mockListPendingInvitationsForEmail: vi.fn(), +})) + +vi.mock('@/lib/invitations/core', () => ({ + getInvitationJoinPreview: mockGetInvitationJoinPreview, + listPendingInvitationsForEmail: mockListPendingInvitationsForEmail, +})) + +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' + +const USER_ID = 'user-1' +const EMAIL = 'invitee@example.com' + +function invitation(id: string) { + return { + id, + kind: 'organization' as const, + email: EMAIL, + organizationId: 'org-1', + organizationName: 'Org', + membershipIntent: 'member', + role: 'member', + status: 'pending', + expiresAt: new Date('2026-02-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + inviterName: 'Ada', + inviterEmail: 'ada@example.com', + grants: [{ workspaceId: 'ws-1', workspaceName: 'WS', permission: 'read' }], + } +} + +describe('listPendingInvitationsForViewer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('pairs each row with its own preview, in order', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b', 'c'].map(invitation)) + mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => ({ for: inv.id })) + + const rows = await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']) + expect(rows.map((r) => r.joinPreview)).toEqual([{ for: 'a' }, { for: 'b' }, { for: 'c' }]) + }) + + /** + * The preview is disclosure-only, so one failing row must degrade to `null` rather than + * hiding the invitation or failing the batch — which is what makes the bounded mapper + * safe, since it fails all-or-nothing on a throwing mapper. + */ + it('degrades a failing preview to null without dropping the invitation', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b'].map(invitation)) + mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => { + if (inv.id === 'a') throw new Error('preview blew up') + return { for: inv.id } + }) + + const rows = await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(rows).toHaveLength(2) + expect(rows[0].joinPreview).toBeNull() + expect(rows[1].joinPreview).toEqual({ for: 'b' }) + }) + + /** + * This runs inside the sidebar prefetch on every workspace page render, and each preview + * issues several queries — so the fan-out stays bounded rather than holding one pooled + * connection per pending invitation. + */ + it('bounds how many previews run at once', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue( + Array.from({ length: 12 }, (_, i) => invitation(`inv-${i}`)) + ) + let inFlight = 0 + let peak = 0 + mockGetInvitationJoinPreview.mockImplementation(async () => { + inFlight++ + peak = Math.max(peak, inFlight) + await Promise.resolve() + inFlight-- + return null + }) + + await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(mockGetInvitationJoinPreview).toHaveBeenCalledTimes(12) + expect(peak).toBeGreaterThan(1) + expect(peak).toBeLessThanOrEqual(4) + }) + + it('serializes dates to the wire shape', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue([invitation('a')]) + mockGetInvitationJoinPreview.mockResolvedValue(null) + + const [row] = await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(row.expiresAt).toBe('2026-02-01T00:00:00.000Z') + expect(row.createdAt).toBe('2026-01-01T00:00:00.000Z') + }) +}) diff --git a/apps/sim/lib/invitations/pending.ts b/apps/sim/lib/invitations/pending.ts new file mode 100644 index 00000000000..86af76bb63b --- /dev/null +++ b/apps/sim/lib/invitations/pending.ts @@ -0,0 +1,82 @@ +import { createLogger } from '@sim/logger' +import type { ViewerInvitation } from '@/lib/api/contracts/invitations' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' + +const logger = createLogger('PendingInvitations') + +/** + * Bounds the join-preview fan-out. A pending-invitation list is a handful of rows, so this + * resolves the common cases in one batch while still capping how many pooled connections a + * single page render can hold. + */ +const INVITATION_PREVIEW_CONCURRENCY = 4 + +/** + * The invitee-facing pending-invitation list behind the workspace switcher's + * Invitations section, assembled once for both the `GET /api/invitations` route + * and the sidebar's server prefetch so both cache one shape. + * + * Rows deliberately exclude the token: acceptance here is session-bound (email + * match), which is what makes it immune to the wrong-browser-account problem. + */ +export async function listPendingInvitationsForViewer( + userId: string, + email: string +): Promise { + const invitations = await listPendingInvitationsForEmail(email) + + /** + * Each row carries what accepting it will actually do, so the client can echo + * `disclosedWorkspaceIds` on accept — the consent contract the emailed + * `/invite` page honours. A preview failure degrades to `null` rather than + * hiding the invitation, which is what lets this run under a bounded mapper. + * + * Bounded rather than unbounded: each preview issues up to three sequential + * queries, so `Promise.all` would hold one pooled connection per pending + * invitation for the length of the slowest one. It was previously a serial + * loop for that reason, which is no longer affordable now that the sidebar + * prefetch calls this on every workspace page render rather than only when + * the switcher opens — a serial loop puts every one of those queries on the + * critical path of the first byte. + */ + const previews = await mapWithConcurrency( + invitations, + INVITATION_PREVIEW_CONCURRENCY, + async (inv) => { + try { + return await getInvitationJoinPreview(userId, inv) + } catch (previewError) { + logger.warn('Failed to compute join preview for pending invitation', { + invitationId: inv.id, + error: previewError, + }) + return null + } + } + ) + + return invitations.map( + (inv, index) => + ({ + id: inv.id, + kind: inv.kind, + email: inv.email, + organizationId: inv.organizationId, + organizationName: inv.organizationName, + membershipIntent: inv.membershipIntent, + role: inv.role, + status: inv.status, + expiresAt: inv.expiresAt.toISOString(), + createdAt: inv.createdAt.toISOString(), + inviterName: inv.inviterName, + inviterEmail: inv.inviterEmail, + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + workspaceName: grant.workspaceName, + permission: grant.permission, + })), + joinPreview: previews[index], + }) satisfies ViewerInvitation + ) +} diff --git a/apps/sim/lib/knowledge/queries.ts b/apps/sim/lib/knowledge/queries.ts new file mode 100644 index 00000000000..a49eb3b9b9a --- /dev/null +++ b/apps/sim/lib/knowledge/queries.ts @@ -0,0 +1,25 @@ +import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' +import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/service' + +/** + * Lists a viewer's knowledge bases in the wire shape the `/api/knowledge` contract declares. + * + * Shared by `GET /api/knowledge` and the Knowledge page's server prefetch so both cache one + * shape. Dates are serialized because `knowledgeBaseDataSchema` types them `z.string()` — + * caching raw `Date`s would violate that type and flip to strings on the next refetch. + */ +export async function listKnowledgeBasesForViewer( + userId: string, + workspaceId?: string | null, + scope: KnowledgeBaseScope = 'active' +): Promise { + const bases = await getKnowledgeBases(userId, workspaceId, scope) + return bases.map((base) => ({ + ...base, + /** Spread so the closed `ChunkingConfig` interface satisfies the schema's open shape. */ + chunkingConfig: { ...base.chunkingConfig }, + createdAt: base.createdAt.toISOString(), + updatedAt: base.updatedAt.toISOString(), + deletedAt: base.deletedAt?.toISOString() ?? null, + })) +} diff --git a/apps/sim/lib/pinned-items/queries.ts b/apps/sim/lib/pinned-items/queries.ts new file mode 100644 index 00000000000..5ca03768c3e --- /dev/null +++ b/apps/sim/lib/pinned-items/queries.ts @@ -0,0 +1,61 @@ +import { db, pinnedItem } from '@sim/db' +import { and, eq, ne } from 'drizzle-orm' +import { type PinnedItemApi, pinnedResourceTypeSchema } from '@/lib/api/contracts' +import type { PinnedResourceType } from '@/lib/api/contracts/pinned-items' +import { filterToActiveResources } from '@/lib/pinned-items/resources' + +/** + * Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does + * not recognise. + * + * `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can + * grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore + * read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE + * list down rather than the single row, so the unknown kind is skipped instead. + * + * `filterToActiveResources` already drops these as a side effect of not having a table to look + * them up in; this makes the guarantee explicit and compiler-checked at the wire boundary. + */ +function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null { + const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType) + if (!resourceType.success) return null + return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() } +} + +/** + * Lists a viewer's pinned items in a workspace, optionally filtered to one `resourceType`, + * already narrowed to the wire shape and to resources that still exist. + * + * Shared by `GET /api/pinned-items` and the resource-list prefetch so both cache one shape — + * pinned ids are the list's primary sort key, so drift reorders the list on the next refetch. + * + * Callers authorize the viewer against `workspaceId` first. + */ +export async function listPinnedItemsForViewer( + userId: string, + workspaceId: string, + resourceType?: PinnedResourceType +): Promise { + const rows = await db + .select() + .from(pinnedItem) + .where( + and( + eq(pinnedItem.userId, userId), + eq(pinnedItem.workspaceId, workspaceId), + /** + * A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise + * appear in this workspace's unscoped listing as a resource *inside* itself. + * It is read from the workspace-list payload instead, so it is excluded here + * rather than left for a future unscoped caller to mistake for a real resource. + */ + resourceType + ? eq(pinnedItem.resourceType, resourceType) + : ne(pinnedItem.resourceType, 'workspace') + ) + ) + + const activeRows = await filterToActiveResources(rows, workspaceId) + + return activeRows.map(toPinnedItemApi).filter((item): item is PinnedItemApi => item !== null) +} diff --git a/apps/sim/lib/workspace-files/queries.test.ts b/apps/sim/lib/workspace-files/queries.test.ts new file mode 100644 index 00000000000..e3212079206 --- /dev/null +++ b/apps/sim/lib/workspace-files/queries.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceShares, mockListWorkspaceFiles } = vi.hoisted(() => ({ + mockGetWorkspaceShares: vi.fn(), + mockListWorkspaceFiles: vi.fn(), +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + getWorkspaceShares: mockGetWorkspaceShares, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, +})) + +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' + +const STORED_FILE = { + id: 'file-1', + workspaceId: 'ws-1', + name: 'notes.md', + key: 'ws-1/notes.md', + path: '/notes.md', + size: 12, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + /** Stored, but absent from `workspaceFileRecordSchema`. */ + contentUpdatedAt: new Date('2026-01-03T00:00:00.000Z'), +} + +describe('listWorkspaceFilesWithShares', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListWorkspaceFiles.mockResolvedValue([STORED_FILE]) + mockGetWorkspaceShares.mockResolvedValue(new Map()) + }) + + /** + * The route and the server prefetch both cache this under one query key, and the client + * parses the response through the same contract. A field the contract does not declare + * would sit in a hydrated entry and vanish on the first refetch. + */ + it('strips fields the response contract does not declare', async () => { + const [file] = await listWorkspaceFilesWithShares('ws-1', 'active') + + expect(file).not.toHaveProperty('contentUpdatedAt') + expect(file.id).toBe('file-1') + expect(file.uploadedAt).toEqual(new Date('2026-01-01T00:00:00.000Z')) + }) + + it('joins each file public share onto its row', async () => { + const share = { + id: 'share-1', + token: 'tok', + url: 'https://sim.ai/f/tok', + isActive: true, + resourceType: 'file' as const, + resourceId: 'file-1', + authType: 'public' as const, + hasPassword: false, + allowedEmails: [], + } + mockGetWorkspaceShares.mockResolvedValue(new Map([['file-1', share]])) + + const [file] = await listWorkspaceFilesWithShares('ws-1', 'active') + + expect(file.share).toEqual(share) + expect(mockGetWorkspaceShares).toHaveBeenCalledWith('file', 'ws-1') + }) +}) diff --git a/apps/sim/lib/workspace-files/queries.ts b/apps/sim/lib/workspace-files/queries.ts new file mode 100644 index 00000000000..de812d2925d --- /dev/null +++ b/apps/sim/lib/workspace-files/queries.ts @@ -0,0 +1,29 @@ +import { listWorkspaceFilesContract } from '@/lib/api/contracts/workspace-files' +import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { + listWorkspaceFiles, + type WorkspaceFileScope, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +/** + * Lists a workspace's files with each file's public share joined on — shared by + * `GET /api/workspaces/[id]/files` and the Files/Home prefetches so both cache one shape. + * + * Parsing through the route contract's response schema strips the server-only fields + * `requestJson` strips on the client (`contentUpdatedAt`), so a prefetched entry is identical + * to a client fetch rather than carrying a field that vanishes on the next refetch. + * + * Callers authorize the viewer against `workspaceId` first. + */ +export async function listWorkspaceFilesWithShares( + workspaceId: string, + scope: WorkspaceFileScope = 'active' +) { + const [files, shares] = await Promise.all([ + listWorkspaceFiles(workspaceId, { scope }), + getWorkspaceShares('file', workspaceId), + ]) + const withShares = files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })) + return listWorkspaceFilesContract.response.schema.parse({ success: true, files: withShares }) + .files +}