diff --git a/apps/sim/app/api/invitations/route.test.ts b/apps/sim/app/api/invitations/route.test.ts new file mode 100644 index 00000000000..4fa986c1603 --- /dev/null +++ b/apps/sim/app/api/invitations/route.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetInvitationJoinPreview, mockGetSession, mockListPendingInvitationsForEmail } = + vi.hoisted(() => ({ + mockGetInvitationJoinPreview: vi.fn(), + mockGetSession: vi.fn(), + mockListPendingInvitationsForEmail: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/invitations/core', () => ({ + getInvitationJoinPreview: mockGetInvitationJoinPreview, + listPendingInvitationsForEmail: mockListPendingInvitationsForEmail, +})) + +import { GET } from '@/app/api/invitations/route' + +function invitation(id: string) { + return { + id, + kind: 'organization', + email: 'invitee@example.com', + 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('GET /api/invitations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'invitee@example.com' }, + }) + }) + + 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 { invitations } = await (await GET(createMockRequest('GET'))).json() + + expect(invitations.map((i: { id: string }) => i.id)).toEqual(['a', 'b', 'c']) + expect(invitations.map((i: { joinPreview: unknown }) => i.joinPreview)).toEqual([ + { for: 'a' }, + { for: 'b' }, + { for: 'c' }, + ]) + }) + + /** + * The preview is disclosure-only, so one failing row degrades to `null` rather than hiding + * the invitation — 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 { invitations } = await (await GET(createMockRequest('GET'))).json() + + expect(invitations).toHaveLength(2) + expect(invitations[0].joinPreview).toBeNull() + expect(invitations[1].joinPreview).toEqual({ for: 'b' }) + }) + + /** + * Each preview issues several queries of its own, so the fan-out stays bounded rather than + * holding one pooled connection per pending invitation. + */ + it('runs previews concurrently, up to a bound', 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 GET(createMockRequest('GET')) + + expect(mockGetInvitationJoinPreview).toHaveBeenCalledTimes(12) + expect(peak).toBe(4) + }) +}) diff --git a/apps/sim/app/api/invitations/route.ts b/apps/sim/app/api/invitations/route.ts index 106a7177799..11b12fbced3 100644 --- a/apps/sim/app/api/invitations/route.ts +++ b/apps/sim/app/api/invitations/route.ts @@ -2,11 +2,15 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getSession } from '@/lib/auth' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' const logger = createLogger('MyInvitationsAPI') +/** Caps how many pooled connections one request can hold; a list is a handful of rows. */ +const INVITATION_PREVIEW_CONCURRENCY = 4 + /** * Pending invitations addressed to the session's email — the invitee-facing * list behind the workspace switcher's Invitations section. Acceptance is @@ -29,24 +33,25 @@ export const GET = withRouteHandler(async () => { * 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. + * Each preview issues up to three queries of its own, so a serial loop put + * every one of them on the critical path of the switcher opening. The mapper + * must stay total — `mapWithConcurrency` fails the whole batch on a throw. */ - 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) + const previews = await mapWithConcurrency( + invitations, + INVITATION_PREVIEW_CONCURRENCY, + async (inv) => { + try { + return await getInvitationJoinPreview(session.user.id, inv) + } catch (previewError) { + logger.warn('Failed to compute join preview for pending invitation', { + invitationId: inv.id, + error: previewError, + }) + return null + } } - } + ) return NextResponse.json({ invitations: invitations.map( 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..250a6e5f713 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/workspace-file-folder-manager' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { WORKSPACE_FILE_FOLDERS_STALE_TIME, @@ -20,32 +20,32 @@ import { * `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}. + * Files and folders read the data layer; both payloads are shaped to their route contract so + * a hydrated entry matches a client fetch. Everything else still goes through its route — + * see {@link prefetchInternalJson}. + * + * Those two reads carry no authorization of their own, so the viewer is proved first. This + * reuses the layout's `cache`d host-context lookup rather than re-deriving the permission, + * so it costs no additional queries; a viewer without access caches nothing and the client + * fetch reaches the route for the real 403. */ export async function prefetchFilesBrowser( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) + if (!hostContext) 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'), 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..4ba194395e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -5,12 +5,12 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' * Server-side GET against an internal `/api` route, forwarding the incoming * request's cookie so the route authenticates as the current user. * - * 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. + * The legacy path. Reading the data layer and shaping the result through the + * route's response contract — as `files/prefetch.ts` does — is canonical: it + * drops a server-to-server request and its duplicate auth, and the contract + * parse is what guarantees the hydrated entry matches a client fetch. Prefetches + * still on this helper have not been converted; a converted one must prove the + * viewer itself, since the route's own authorization no longer runs. */ export async function prefetchInternalJson(path: string): Promise { const cookie = (await headers()).get('cookie') diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 392202a2224..7d701d22066 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -4,10 +4,28 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPrefetchInternalJson } = vi.hoisted(() => ({ +const { + mockGetWorkspaceHostContextForViewer, + mockListWorkspaceFileFolders, + mockListWorkspaceFilesWithShares, + mockPrefetchInternalJson, +} = vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockListWorkspaceFileFolders: vi.fn(), + mockListWorkspaceFilesWithShares: vi.fn(), mockPrefetchInternalJson: vi.fn(), })) +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, +})) +vi.mock('@/lib/workspace-files/queries', () => ({ + listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + listWorkspaceFileFolders: mockListWorkspaceFileFolders, +})) + vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ prefetchInternalJson: mockPrefetchInternalJson, })) @@ -29,6 +47,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,6 +56,9 @@ function makeClient() { describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ viewer: { permission: 'admin' } }) + mockListWorkspaceFilesWithShares.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) }) describe('prefetchKnowledgeBases', () => { @@ -73,34 +95,32 @@ 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: [] } - ) + /** + * The reads bypass the route that used to authorize them, so a viewer without workspace + * access must prime nothing and let the client fetch reach the route for the real 403. + */ + it('caches nothing when the viewer has no workspace access', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) const client = makeClient() - await prefetchFilesBrowser(client, WORKSPACE_ID) + await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual([]) + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() }) }) @@ -111,9 +131,21 @@ describe('workspace list prefetches', () => { * column. Both must be primed on every foldered page, under the exact client keys. */ const chromeCases = [ - { name: 'files', run: prefetchFilesBrowser, resourceType: 'file' as const }, - { name: 'tables', run: prefetchTables, resourceType: 'table' as const }, - { name: 'knowledge', run: prefetchKnowledgeBases, resourceType: 'knowledge_base' as const }, + { + name: 'files', + run: (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), + resourceType: 'file' as const, + }, + { + name: 'tables', + run: (client: QueryClient) => prefetchTables(client, WORKSPACE_ID), + resourceType: 'table' as const, + }, + { + name: 'knowledge', + run: (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID), + resourceType: 'knowledge_base' as const, + }, ] for (const { name, run, resourceType } of chromeCases) { @@ -128,7 +160,7 @@ describe('workspace list prefetches', () => { }) const client = makeClient() - await run(client, WORKSPACE_ID) + await run(client) expect(mockPrefetchInternalJson).toHaveBeenCalledWith( `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=${resourceType}` @@ -193,23 +225,32 @@ describe('workspace list prefetches', () => { it.each([ [ 'prefetchKnowledgeBases', - prefetchKnowledgeBases, + (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID), knowledgeKeys.list(WORKSPACE_ID, 'active'), ], - ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], - ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], + [ + 'prefetchTables', + (client: QueryClient) => prefetchTables(client, WORKSPACE_ID), + tableKeys.list(WORKSPACE_ID, 'active'), + ], + [ + 'prefetchHomeLists', + (client: QueryClient) => prefetchHomeLists(client, WORKSPACE_ID), + folderKeys.list(WORKSPACE_ID, 'active'), + ], [ 'prefetchFilesBrowser', - prefetchFilesBrowser, + (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), 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')) + mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500')) const client = makeClient() - await expect(prefetch(client, WORKSPACE_ID)).resolves.toBeUndefined() + await expect(prefetch(client)).resolves.toBeUndefined() expect(client.getQueryData(queryKey)).toBeUndefined() } ) 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..9a5f0d6b8ca --- /dev/null +++ b/apps/sim/lib/workspace-files/queries.ts @@ -0,0 +1,26 @@ +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 browser's server prefetch 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) { + 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.shape.files.parse(withShares) +}