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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 5 additions & 58 deletions apps/sim/app/api/invitations/route.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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<Awaited<ReturnType<typeof getInvitationJoinPreview>> | 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 })
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/knowledge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 3 additions & 45 deletions apps/sim/app/api/pinned-items/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 })
})
Expand Down
20 changes: 6 additions & 14 deletions apps/sim/app/api/workspaces/[id]/files/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
36 changes: 15 additions & 21 deletions apps/sim/app/workspace/[workspaceId]/files/prefetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void> {
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<ListWorkspaceFilesResponse>(
`/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'),
])
}
9 changes: 5 additions & 4 deletions apps/sim/app/workspace/[workspaceId]/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
32 changes: 14 additions & 18 deletions apps/sim/app/workspace/[workspaceId]/home/prefetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void> {
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<ListWorkspaceFilesResponse>(
`/api/workspaces/${workspaceId}/files?scope=active`
)
return data.success ? data.files : []
},
queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'),
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
}),
])
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
Loading
Loading