From 98635783374185194d8774541a87ba1a3d021dd1 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 18:43:51 -0700 Subject: [PATCH 1/4] fix(sidebar): prefetch chats + workflows so cold loads don't flash skeletons On a cold load (e.g. when the browser discards an idle tab and reloads), the persistent sidebar started with an empty React Query cache and client-fetched its chat + workflow lists, flashing loading skeletons. Prefetch both lists server-side in the workspace layout and hydrate them via HydrationBoundary, under the same query keys and mappers the client hooks use, so the sidebar paints populated on the first render. The prefetch runs concurrently with the existing org-settings fetch and never throws, so it adds no blocking work in the common case and falls back to client fetching on error. --- .../app/workspace/[workspaceId]/layout.tsx | 28 ++++++++- .../app/workspace/[workspaceId]/prefetch.ts | 57 +++++++++++++++++++ apps/sim/hooks/queries/mothership-chats.ts | 2 +- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/prefetch.ts diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 3dc8aadf665..c36be77c95b 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,8 +1,14 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { redirect } from 'next/navigation' import { ToastProvider } from '@/components/emcn' import { getSession } from '@/lib/auth' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { + prefetchSidebarChats, + prefetchSidebarWorkflows, +} from '@/app/workspace/[workspaceId]/prefetch' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -11,15 +17,31 @@ import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/work import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider' import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding' -export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) { +export default async function WorkspaceLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ workspaceId: string }> +}) { const session = await getSession() if (!session?.user) { redirect('/login') } + + const { workspaceId } = await params + const queryClient = getQueryClient() + const sidebarPrefetch = Promise.all([ + prefetchSidebarWorkflows(queryClient, workspaceId), + prefetchSidebarChats(queryClient, workspaceId), + ]) + // The organization plugin is conditionally spread so TS can't infer activeOrganizationId on the base session type. const orgId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null + await sidebarPrefetch + return ( @@ -30,7 +52,9 @@ export default async function WorkspaceLayout({ children }: { children: React.Re - {children} + + {children} + diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts new file mode 100644 index 00000000000..4db97c99656 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -0,0 +1,57 @@ +import type { QueryClient } from '@tanstack/react-query' +import { headers } from 'next/headers' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { mapChat, mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' + +/** Forwards incoming request cookies so server-side API fetches authenticate correctly. */ +async function getForwardedHeaders(): Promise> { + const h = await headers() + const cookie = h.get('cookie') + return cookie ? { cookie } : {} +} + +/** + * Prefetches the workspace's workflow list under the same key and mapping as the client + * `useWorkflows`/`useWorkflowMap` hooks, so the dehydrated data hydrates the sidebar. + */ +export function prefetchSidebarWorkflows(queryClient: QueryClient, workspaceId: string) { + return queryClient.prefetchQuery({ + queryKey: workflowKeys.list(workspaceId, 'active'), + queryFn: async () => { + const fwdHeaders = await getForwardedHeaders() + const baseUrl = getInternalApiBaseUrl() + const response = await fetch( + `${baseUrl}/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}&scope=active`, + { headers: fwdHeaders } + ) + if (!response.ok) throw new Error(`Workflows prefetch failed: ${response.status}`) + const { data } = await response.json() + return data.map(mapWorkflow) + }, + staleTime: WORKFLOW_LIST_STALE_TIME, + }) +} + +/** + * Prefetches the workspace's mothership chat list under the same key and mapping as the + * client `useMothershipChats` hook, so the dehydrated data hydrates the sidebar. + */ +export function prefetchSidebarChats(queryClient: QueryClient, workspaceId: string) { + return queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.list(workspaceId), + queryFn: async () => { + const fwdHeaders = await getForwardedHeaders() + const baseUrl = getInternalApiBaseUrl() + const response = await fetch( + `${baseUrl}/api/mothership/chats?workspaceId=${encodeURIComponent(workspaceId)}`, + { headers: fwdHeaders } + ) + if (!response.ok) throw new Error(`Chats prefetch failed: ${response.status}`) + const { data } = await response.json() + return data.map(mapChat) + }, + staleTime: 60 * 1000, + }) +} diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 01a4bf01a6c..fb2572267a9 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -183,7 +183,7 @@ function parseChatResourcesResponse(value: unknown): { resources: MothershipReso } } -function mapChat(chat: MothershipChat): MothershipChatMetadata { +export function mapChat(chat: MothershipChat): MothershipChatMetadata { const updatedAt = new Date(chat.updatedAt) return { id: chat.id, From a3eb606f5e7f59fe2c2861afc86ea4af5597ca3e Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 19:02:19 -0700 Subject: [PATCH 2/4] refactor(prefetch): call data layer directly instead of internal HTTP self-fetch The sidebar and settings prefetches fetched their data by making internal HTTP requests to our own API routes. Replace those self-fetches with direct calls to shared server-side data functions, so each route handler and its prefetch read from one source with no extra network hop, serialization, or re-auth. - Extract listWorkflowsForUser (lib/workflows/queries) and listMothershipChats (lib/copilot/chat) from their routes; both routes and the sidebar prefetch now call them. - Extract getUserSettings/getUserProfile (lib/users/queries) shared by the settings/profile routes and their prefetches. - Subscription prefetch calls the existing getSimplifiedBillingSummary + getEffectiveBillingStatus directly. - Sidebar prefetch checks workspace access once via checkWorkspaceAccess and skips silently when denied. --- apps/sim/app/api/mothership/chats/route.ts | 33 +----- apps/sim/app/api/users/me/profile/route.ts | 13 +-- apps/sim/app/api/users/me/settings/route.ts | 75 +------------ apps/sim/app/api/workflows/route.ts | 65 +---------- .../app/workspace/[workspaceId]/layout.tsx | 10 +- .../app/workspace/[workspaceId]/prefetch.ts | 90 ++++++++-------- .../settings/[section]/prefetch.ts | 65 ++++++----- .../lib/copilot/chat/list-mothership-chats.ts | 50 +++++++++ apps/sim/lib/users/queries.ts | 96 +++++++++++++++++ apps/sim/lib/workflows/queries.ts | 101 ++++++++++++++++++ 10 files changed, 334 insertions(+), 264 deletions(-) create mode 100644 apps/sim/lib/copilot/chat/list-mothership-chats.ts create mode 100644 apps/sim/lib/users/queries.ts create mode 100644 apps/sim/lib/workflows/queries.ts diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 7f0b5828500..764b897eac3 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -1,14 +1,13 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createMothershipChatContract, listMothershipChatsContract, } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { chatPubSub } from '@/lib/copilot/chat-status' import { authenticateCopilotRequestSessionOnly, @@ -42,35 +41,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await assertActiveWorkspaceAccess(workspaceId, userId) - const chats = await db - .select({ - id: copilotChats.id, - title: copilotChats.title, - updatedAt: copilotChats.updatedAt, - activeStreamId: copilotChats.conversationId, - lastSeenAt: copilotChats.lastSeenAt, - pinned: copilotChats.pinned, - }) - .from(copilotChats) - .where( - and( - eq(copilotChats.userId, userId), - eq(copilotChats.workspaceId, workspaceId), - eq(copilotChats.type, 'mothership') - ) - ) - .orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt)) - - const streamMarkers = await reconcileChatStreamMarkers( - chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })), - { repairVerifiedStaleMarkers: true } - ) - const reconciled = chats.map((c) => { - const activeStreamId = streamMarkers.get(c.id)?.streamId ?? null - return activeStreamId === c.activeStreamId ? c : { ...c, activeStreamId } - }) + const data = await listMothershipChats(userId, workspaceId) - return NextResponse.json({ success: true, data: reconciled }) + return NextResponse.json({ success: true, data }) } catch (error) { if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts index 81336d60dec..b71fa183f21 100644 --- a/apps/sim/app/api/users/me/profile/route.ts +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -8,6 +8,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserProfile } from '@/lib/users/queries' const logger = createLogger('UpdateUserProfileAPI') @@ -84,17 +85,7 @@ export const GET = withRouteHandler(async () => { const userId = session.user.id - const [userRecord] = await db - .select({ - id: user.id, - name: user.name, - email: user.email, - image: user.image, - emailVerified: user.emailVerified, - }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) + const userRecord = await getUserProfile(userId) if (!userRecord) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 7e07b2b38e5..24ccacceb62 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -2,93 +2,26 @@ import { db } from '@sim/db' import { settings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateUserSettingsContract } from '@/lib/api/contracts' import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { defaultUserSettings, getUserSettings } from '@/lib/users/queries' const logger = createLogger('UserSettingsAPI') -const defaultSettings = { - theme: 'system', - autoConnect: true, - telemetryEnabled: true, - emailPreferences: {}, - billingUsageNotificationsEnabled: true, - showTrainingControls: false, - superUserModeEnabled: false, - mothershipEnvironment: 'default', - errorNotificationsEnabled: true, - snapToGridSize: 0, - showActionBar: true, - timezone: null, - lastActiveWorkspaceId: null, -} - export const GET = withRouteHandler(async () => { const requestId = generateRequestId() try { const session = await getSession() - - if (!session?.user?.id) { - logger.info(`[${requestId}] Returning default settings for unauthenticated user`) - return NextResponse.json({ data: defaultSettings }, { status: 200 }) - } - - const userId = session.user.id - const result = await db - .select({ - theme: settings.theme, - autoConnect: settings.autoConnect, - telemetryEnabled: settings.telemetryEnabled, - emailPreferences: settings.emailPreferences, - billingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled, - showTrainingControls: settings.showTrainingControls, - superUserModeEnabled: settings.superUserModeEnabled, - mothershipEnvironment: settings.mothershipEnvironment, - errorNotificationsEnabled: settings.errorNotificationsEnabled, - snapToGridSize: settings.snapToGridSize, - showActionBar: settings.showActionBar, - timezone: settings.timezone, - lastActiveWorkspaceId: settings.lastActiveWorkspaceId, - }) - .from(settings) - .where(eq(settings.userId, userId)) - .limit(1) - - if (!result.length) { - return NextResponse.json({ data: defaultSettings }, { status: 200 }) - } - - const userSettings = result[0] - - return NextResponse.json( - { - data: { - theme: userSettings.theme, - autoConnect: userSettings.autoConnect, - telemetryEnabled: userSettings.telemetryEnabled, - emailPreferences: userSettings.emailPreferences ?? {}, - billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true, - showTrainingControls: userSettings.showTrainingControls ?? false, - superUserModeEnabled: userSettings.superUserModeEnabled ?? false, - mothershipEnvironment: userSettings.mothershipEnvironment ?? 'default', - errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true, - snapToGridSize: userSettings.snapToGridSize ?? 0, - showActionBar: userSettings.showActionBar ?? true, - timezone: userSettings.timezone ?? null, - lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null, - }, - }, - { status: 200 } - ) + const data = await getUserSettings(session?.user?.id ?? null) + return NextResponse.json({ data }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Settings fetch error`, error) - return NextResponse.json({ data: defaultSettings }, { status: 200 }) + return NextResponse.json({ data: defaultUserSettings }, { status: 200 }) } }) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 6a8738869f7..92a17985965 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { permissions, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -10,12 +7,12 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowAPI') -// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId) export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() @@ -63,65 +60,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } - let workflows - - /** - * Project only the columns declared in `workflowListItemSchema` so the - * wire response matches the contract shape exactly. The full row is - * larger (`state`, `variables`, `apiKey`, `runCount`, etc.) and would - * be dropped client-side by Zod parse anyway — narrowing here saves - * bytes over the wire. Keep this list aligned with the contract. - */ - const listColumns = { - id: workflow.id, - name: workflow.name, - description: workflow.description, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - archivedAt: workflow.archivedAt, - locked: workflow.locked, - } as const - const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] - - if (workspaceId) { - workflows = await db - .select(listColumns) - .from(workflow) - .where( - scope === 'all' - ? eq(workflow.workspaceId, workspaceId) - : scope === 'archived' - ? and(eq(workflow.workspaceId, workspaceId), sql`${workflow.archivedAt} IS NOT NULL`) - : and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)) - ) - .orderBy(...orderByClause) - } else { - const workspacePermissionRows = await db - .select({ workspaceId: permissions.entityId }) - .from(permissions) - .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) - const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId) - if (workspaceIds.length === 0) { - return NextResponse.json({ data: [] }, { status: 200 }) - } - workflows = await db - .select(listColumns) - .from(workflow) - .where( - scope === 'all' - ? inArray(workflow.workspaceId, workspaceIds) - : scope === 'archived' - ? and( - inArray(workflow.workspaceId, workspaceIds), - sql`${workflow.archivedAt} IS NOT NULL` - ) - : and(inArray(workflow.workspaceId, workspaceIds), isNull(workflow.archivedAt)) - ) - .orderBy(...orderByClause) - } + const workflows = await listWorkflowsForUser({ userId, workspaceId, scope }) return NextResponse.json({ data: workflows }, { status: 200 }) } catch (error: any) { diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index c36be77c95b..ddc004a8724 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -5,10 +5,7 @@ import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' -import { - prefetchSidebarChats, - prefetchSidebarWorkflows, -} from '@/app/workspace/[workspaceId]/prefetch' +import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -31,10 +28,7 @@ export default async function WorkspaceLayout({ const { workspaceId } = await params const queryClient = getQueryClient() - const sidebarPrefetch = Promise.all([ - prefetchSidebarWorkflows(queryClient, workspaceId), - prefetchSidebarChats(queryClient, workspaceId), - ]) + const sidebarPrefetch = prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id) // The organization plugin is conditionally spread so TS can't infer activeOrganizationId on the base session type. const orgId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 4db97c99656..41a65fa92bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,57 +1,55 @@ import type { QueryClient } from '@tanstack/react-query' -import { headers } from 'next/headers' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { listWorkflowsForUser } from '@/lib/workflows/queries' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { mapChat, mothershipChatKeys } from '@/hooks/queries/mothership-chats' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' -/** Forwards incoming request cookies so server-side API fetches authenticate correctly. */ -async function getForwardedHeaders(): Promise> { - const h = await headers() - const cookie = h.get('cookie') - return cookie ? { cookie } : {} -} +const CHAT_LIST_STALE_TIME = 60 * 1000 -/** - * Prefetches the workspace's workflow list under the same key and mapping as the client - * `useWorkflows`/`useWorkflowMap` hooks, so the dehydrated data hydrates the sidebar. - */ -export function prefetchSidebarWorkflows(queryClient: QueryClient, workspaceId: string) { - return queryClient.prefetchQuery({ - queryKey: workflowKeys.list(workspaceId, 'active'), - queryFn: async () => { - const fwdHeaders = await getForwardedHeaders() - const baseUrl = getInternalApiBaseUrl() - const response = await fetch( - `${baseUrl}/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}&scope=active`, - { headers: fwdHeaders } - ) - if (!response.ok) throw new Error(`Workflows prefetch failed: ${response.status}`) - const { data } = await response.json() - return data.map(mapWorkflow) - }, - staleTime: WORKFLOW_LIST_STALE_TIME, - }) +/** Resolves whether the user may access the workspace, swallowing errors to a `false`. */ +async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise { + try { + const access = await checkWorkspaceAccess(workspaceId, userId) + return access.exists && access.hasAccess + } catch { + return false + } } /** - * Prefetches the workspace's mothership chat list under the same key and mapping as the - * client `useMothershipChats` hook, so the dehydrated data hydrates the sidebar. + * Prefetches the sidebar's workflow + chat lists for a workspace and stores them + * under the same query keys + mappers the client hooks use, so the persistent + * sidebar paints populated on the first server render instead of flashing skeletons + * on a cold load (e.g. after the browser discards an idle tab). Calls the data layer + * directly — the same functions the API routes use — with no internal HTTP hop. + * + * Skips silently when the user can't access the workspace, leaving the client to + * fetch and surface the real error instead of caching an empty list. */ -export function prefetchSidebarChats(queryClient: QueryClient, workspaceId: string) { - return queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.list(workspaceId), - queryFn: async () => { - const fwdHeaders = await getForwardedHeaders() - const baseUrl = getInternalApiBaseUrl() - const response = await fetch( - `${baseUrl}/api/mothership/chats?workspaceId=${encodeURIComponent(workspaceId)}`, - { headers: fwdHeaders } - ) - if (!response.ok) throw new Error(`Chats prefetch failed: ${response.status}`) - const { data } = await response.json() - return data.map(mapChat) - }, - staleTime: 60 * 1000, - }) +export async function prefetchWorkspaceSidebar( + queryClient: QueryClient, + workspaceId: string, + userId: string +): Promise { + if (!(await userCanAccessWorkspace(workspaceId, userId))) return + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: workflowKeys.list(workspaceId, 'active'), + queryFn: async () => { + const rows = await listWorkflowsForUser({ userId, workspaceId, scope: 'active' }) + return rows.map(mapWorkflow) + }, + staleTime: WORKFLOW_LIST_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.list(workspaceId), + queryFn: async () => { + const data = await listMothershipChats(userId, workspaceId) + return data.map(mapChat) + }, + staleTime: CHAT_LIST_STALE_TIME, + }), + ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index d04d9481d1a..041f6d26620 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -1,21 +1,15 @@ +import { dbReplica } from '@sim/db' import type { QueryClient } from '@tanstack/react-query' -import { headers } from 'next/headers' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { getSession } from '@/lib/auth' +import { getEffectiveBillingStatus } from '@/lib/billing/core/access' +import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' +import { getUserProfile, getUserSettings } from '@/lib/users/queries' import { generalSettingsKeys, mapGeneralSettingsResponse } from '@/hooks/queries/general-settings' import { subscriptionKeys } from '@/hooks/queries/subscription' import { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile' /** - * Forwards incoming request cookies so server-side API fetches authenticate correctly. - */ -async function getForwardedHeaders(): Promise> { - const h = await headers() - const cookie = h.get('cookie') - return cookie ? { cookie } : {} -} - -/** - * Prefetch general settings server-side via internal API fetch. + * Prefetch general settings server-side via the shared data layer. * Uses the same query keys as the client `useGeneralSettings` hook * so data is shared via HydrationBoundary. */ @@ -23,13 +17,8 @@ export function prefetchGeneralSettings(queryClient: QueryClient) { return queryClient.prefetchQuery({ queryKey: generalSettingsKeys.settings(), queryFn: async () => { - const fwdHeaders = await getForwardedHeaders() - const baseUrl = getInternalApiBaseUrl() - const response = await fetch(`${baseUrl}/api/users/me/settings`, { - headers: fwdHeaders, - }) - if (!response.ok) throw new Error(`Settings prefetch failed: ${response.status}`) - const { data } = await response.json() + const session = await getSession() + const data = await getUserSettings(session?.user?.id ?? null) return mapGeneralSettingsResponse(data) }, staleTime: 60 * 60 * 1000, @@ -37,7 +26,7 @@ export function prefetchGeneralSettings(queryClient: QueryClient) { } /** - * Prefetch subscription data server-side via internal API fetch. + * Prefetch subscription data server-side via the shared data layer. * Uses the same query key as the client `useSubscriptionData` hook (with includeOrg=false) * so data is shared via HydrationBoundary — ensuring the settings sidebar renders * with the correct Team/Enterprise tabs on the first paint, with no flash. @@ -46,20 +35,29 @@ export function prefetchSubscriptionData(queryClient: QueryClient) { return queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(false), queryFn: async () => { - const fwdHeaders = await getForwardedHeaders() - const baseUrl = getInternalApiBaseUrl() - const response = await fetch(`${baseUrl}/api/billing?context=user`, { - headers: fwdHeaders, - }) - if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`) - return response.json() + const session = await getSession() + if (!session?.user?.id) throw new Error('Unauthorized') + const [summary, status] = await Promise.all([ + getSimplifiedBillingSummary(session.user.id, undefined, dbReplica), + getEffectiveBillingStatus(session.user.id), + ]) + return { + success: true, + context: 'user' as const, + data: { + ...summary, + billingBlocked: status.billingBlocked, + billingBlockedReason: status.billingBlockedReason, + blockedByOrgOwner: status.blockedByOrgOwner, + }, + } }, staleTime: 5 * 60 * 1000, }) } /** - * Prefetch user profile server-side via internal API fetch. + * Prefetch user profile server-side via the shared data layer. * Uses the same query keys as the client `useUserProfile` hook * so data is shared via HydrationBoundary. */ @@ -67,13 +65,10 @@ export function prefetchUserProfile(queryClient: QueryClient) { return queryClient.prefetchQuery({ queryKey: userProfileKeys.profile(), queryFn: async () => { - const fwdHeaders = await getForwardedHeaders() - const baseUrl = getInternalApiBaseUrl() - const response = await fetch(`${baseUrl}/api/users/me/profile`, { - headers: fwdHeaders, - }) - if (!response.ok) throw new Error(`Profile prefetch failed: ${response.status}`) - const { user } = await response.json() + const session = await getSession() + if (!session?.user?.id) throw new Error('Unauthorized') + const user = await getUserProfile(session.user.id) + if (!user) throw new Error('User not found') return mapUserProfileResponse(user) }, staleTime: 5 * 60 * 1000, diff --git a/apps/sim/lib/copilot/chat/list-mothership-chats.ts b/apps/sim/lib/copilot/chat/list-mothership-chats.ts new file mode 100644 index 00000000000..73839785a75 --- /dev/null +++ b/apps/sim/lib/copilot/chat/list-mothership-chats.ts @@ -0,0 +1,50 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { and, desc, eq } from 'drizzle-orm' +import type { MothershipChat } from '@/lib/api/contracts/mothership-chats' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' + +/** + * Lists a user's mothership (home) chats for a workspace as the contract wire + * shape, shared by the `GET /api/mothership/chats` route and the workspace + * sidebar prefetch. Performs no auth or workspace-access checks — callers + * enforce access before invoking. Reconciles stale live-stream markers and + * normalizes timestamps to ISO strings to honor the wire contract. + */ +export async function listMothershipChats( + userId: string, + workspaceId: string +): Promise { + const chats = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + updatedAt: copilotChats.updatedAt, + activeStreamId: copilotChats.conversationId, + lastSeenAt: copilotChats.lastSeenAt, + pinned: copilotChats.pinned, + }) + .from(copilotChats) + .where( + and( + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership') + ) + ) + .orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt)) + + const streamMarkers = await reconcileChatStreamMarkers( + chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })), + { repairVerifiedStaleMarkers: true } + ) + + return chats.map((c) => ({ + id: c.id, + title: c.title, + updatedAt: c.updatedAt.toISOString(), + activeStreamId: streamMarkers.get(c.id)?.streamId ?? null, + lastSeenAt: c.lastSeenAt ? c.lastSeenAt.toISOString() : null, + pinned: c.pinned, + })) +} diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts new file mode 100644 index 00000000000..0098efc2470 --- /dev/null +++ b/apps/sim/lib/users/queries.ts @@ -0,0 +1,96 @@ +import { db } from '@sim/db' +import { settings, user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { UserSettingsApi } from '@/lib/api/contracts/user' + +/** + * Default user settings returned for unauthenticated users or when no + * settings row exists yet. + */ +export const defaultUserSettings: UserSettingsApi = { + theme: 'system', + autoConnect: true, + telemetryEnabled: true, + emailPreferences: {}, + billingUsageNotificationsEnabled: true, + showTrainingControls: false, + superUserModeEnabled: false, + mothershipEnvironment: 'default', + errorNotificationsEnabled: true, + snapToGridSize: 0, + showActionBar: true, + timezone: null, + lastActiveWorkspaceId: null, +} + +/** + * Loads a user's settings, falling back to {@link defaultUserSettings} when the + * user is unauthenticated or has no persisted settings row. + */ +export async function getUserSettings(userId: string | null): Promise { + if (!userId) { + return defaultUserSettings + } + + const result = await db + .select({ + theme: settings.theme, + autoConnect: settings.autoConnect, + telemetryEnabled: settings.telemetryEnabled, + emailPreferences: settings.emailPreferences, + billingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled, + showTrainingControls: settings.showTrainingControls, + superUserModeEnabled: settings.superUserModeEnabled, + mothershipEnvironment: settings.mothershipEnvironment, + errorNotificationsEnabled: settings.errorNotificationsEnabled, + snapToGridSize: settings.snapToGridSize, + showActionBar: settings.showActionBar, + timezone: settings.timezone, + lastActiveWorkspaceId: settings.lastActiveWorkspaceId, + }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1) + + if (!result.length) { + return defaultUserSettings + } + + const userSettings = result[0] + + return { + theme: userSettings.theme as UserSettingsApi['theme'], + autoConnect: userSettings.autoConnect, + telemetryEnabled: userSettings.telemetryEnabled, + emailPreferences: userSettings.emailPreferences ?? {}, + billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true, + showTrainingControls: userSettings.showTrainingControls ?? false, + superUserModeEnabled: userSettings.superUserModeEnabled ?? false, + mothershipEnvironment: + (userSettings.mothershipEnvironment as UserSettingsApi['mothershipEnvironment']) ?? 'default', + errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true, + snapToGridSize: userSettings.snapToGridSize ?? 0, + showActionBar: userSettings.showActionBar ?? true, + timezone: userSettings.timezone ?? null, + lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null, + } +} + +/** + * Loads a user's public profile fields, or `null` when no matching user exists. + */ +export async function getUserProfile(userId: string) { + const [userRecord] = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + emailVerified: user.emailVerified, + }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + return userRecord ?? null +} diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts new file mode 100644 index 00000000000..751cbd23695 --- /dev/null +++ b/apps/sim/lib/workflows/queries.ts @@ -0,0 +1,101 @@ +import { db } from '@sim/db' +import { permissions, workflow } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import type { WorkflowListItem } from '@/lib/api/contracts/workflows' + +type WorkflowListScope = 'active' | 'archived' | 'all' + +/** + * Project only the columns declared in `workflowListItemSchema` so the result + * matches the contract wire shape exactly. The full row is larger (`state`, + * `variables`, `apiKey`, `runCount`, etc.) and would be dropped by the client + * Zod parse anyway — narrowing here keeps the payload small. Keep aligned with + * the contract. + */ +const listColumns = { + id: workflow.id, + name: workflow.name, + description: workflow.description, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + archivedAt: workflow.archivedAt, + locked: workflow.locked, +} as const + +const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] + +type WorkflowListRow = { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + sortOrder: number + createdAt: Date + updatedAt: Date + archivedAt: Date | null + locked: boolean +} + +/** Normalizes timestamp columns to ISO strings to honor the `WorkflowListItem` wire contract. */ +function toListItem(row: WorkflowListRow): WorkflowListItem { + return { + ...row, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + archivedAt: row.archivedAt ? row.archivedAt.toISOString() : null, + } +} + +function scopeCondition( + scope: WorkflowListScope, + base: ReturnType | ReturnType +) { + if (scope === 'all') return base + if (scope === 'archived') return and(base, sql`${workflow.archivedAt} IS NOT NULL`) + return and(base, isNull(workflow.archivedAt)) +} + +/** + * Lists workflows visible to a user as the contract wire shape, shared by the + * `GET /api/workflows` route and the workspace sidebar prefetch. Performs no auth + * or membership checks — callers enforce access before invoking. + * + * With `workspaceId`, returns that workspace's workflows; without it, returns + * workflows across every workspace the user has permissions on. + */ +export async function listWorkflowsForUser({ + userId, + workspaceId, + scope, +}: { + userId: string + workspaceId?: string + scope: WorkflowListScope +}): Promise { + if (workspaceId) { + const rows = await db + .select(listColumns) + .from(workflow) + .where(scopeCondition(scope, eq(workflow.workspaceId, workspaceId))) + .orderBy(...orderByClause) + return rows.map(toListItem) + } + + const workspacePermissionRows = await db + .select({ workspaceId: permissions.entityId }) + .from(permissions) + .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) + const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId) + if (workspaceIds.length === 0) return [] + + const rows = await db + .select(listColumns) + .from(workflow) + .where(scopeCondition(scope, inArray(workflow.workspaceId, workspaceIds))) + .orderBy(...orderByClause) + return rows.map(toListItem) +} From 588271f574d405b38d3ca9867382f2d5113032fa Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 19:08:37 -0700 Subject: [PATCH 3/4] refactor(prefetch): share mothership chat list staleTime constant Export MOTHERSHIP_CHAT_LIST_STALE_TIME from the chats hook and use it in both useMothershipChats and the sidebar prefetch, mirroring WORKFLOW_LIST_STALE_TIME so the prefetch and client hook can't drift. --- apps/sim/app/workspace/[workspaceId]/prefetch.ts | 10 ++++++---- apps/sim/hooks/queries/mothership-chats.ts | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 41a65fa92bf..9eebb7f56ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -2,12 +2,14 @@ import type { QueryClient } from '@tanstack/react-query' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { mapChat, mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mapChat, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' -const CHAT_LIST_STALE_TIME = 60 * 1000 - /** Resolves whether the user may access the workspace, swallowing errors to a `false`. */ async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise { try { @@ -49,7 +51,7 @@ export async function prefetchWorkspaceSidebar( const data = await listMothershipChats(userId, workspaceId) return data.map(mapChat) }, - staleTime: CHAT_LIST_STALE_TIME, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, }), ]) } diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index fb2572267a9..10e458c5aef 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -61,6 +61,9 @@ export const mothershipChatKeys = { detail: (chatId: string | undefined) => [...mothershipChatKeys.details(), chatId ?? ''] as const, } +/** Shared by the `useMothershipChats` hook and the workspace sidebar prefetch. */ +export const MOTHERSHIP_CHAT_LIST_STALE_TIME = 60 * 1000 + function assertValid(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message) @@ -217,7 +220,7 @@ export function useMothershipChats(workspaceId?: string) { queryKey: mothershipChatKeys.list(workspaceId), queryFn: workspaceId ? ({ signal }) => fetchMothershipChats(workspaceId, signal) : skipToken, placeholderData: keepPreviousData, - staleTime: 60 * 1000, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, }) } From 7de9458016fd1eaa96379a7ceab0186e5f2ac367 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 19:21:03 -0700 Subject: [PATCH 4/4] fix(prefetch): keep subscription prefetch on the wire shape via internal billing API The billing summary returns Date fields (and an untyped metadata blob) that the JSON API serializes to strings. Calling the data layer directly would cache Date objects (App Router preserves them through RSC serialization), mismatching the string wire shape the client useSubscriptionData hook caches. Route the subscription prefetch through the internal billing API so server-hydrated and client-fetched data share the exact same shape. The date-free general-settings and profile prefetches keep calling the data layer directly. --- .../settings/[section]/prefetch.ts | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 041f6d26620..0df3a17358e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -1,8 +1,7 @@ -import { dbReplica } from '@sim/db' import type { QueryClient } from '@tanstack/react-query' +import { headers } from 'next/headers' import { getSession } from '@/lib/auth' -import { getEffectiveBillingStatus } from '@/lib/billing/core/access' -import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { getUserProfile, getUserSettings } from '@/lib/users/queries' import { generalSettingsKeys, mapGeneralSettingsResponse } from '@/hooks/queries/general-settings' import { subscriptionKeys } from '@/hooks/queries/subscription' @@ -26,31 +25,26 @@ export function prefetchGeneralSettings(queryClient: QueryClient) { } /** - * Prefetch subscription data server-side via the shared data layer. - * Uses the same query key as the client `useSubscriptionData` hook (with includeOrg=false) - * so data is shared via HydrationBoundary — ensuring the settings sidebar renders - * with the correct Team/Enterprise tabs on the first paint, with no flash. + * Prefetch subscription data server-side. Unlike the other prefetches this goes + * through the internal billing API rather than calling the data layer directly: + * the billing summary contains `Date` fields (and an untyped `metadata` blob) that + * `NextResponse.json` serializes to the string wire shape the client caches. Going + * through the route yields that exact shape, avoiding a Date-vs-string mismatch + * between server-hydrated and client-fetched data. Uses the same query key as the + * client `useSubscriptionData` hook (with includeOrg=false) so data is shared via + * HydrationBoundary. */ export function prefetchSubscriptionData(queryClient: QueryClient) { return queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(false), queryFn: async () => { - const session = await getSession() - if (!session?.user?.id) throw new Error('Unauthorized') - const [summary, status] = await Promise.all([ - getSimplifiedBillingSummary(session.user.id, undefined, dbReplica), - getEffectiveBillingStatus(session.user.id), - ]) - return { - success: true, - context: 'user' as const, - data: { - ...summary, - billingBlocked: status.billingBlocked, - billingBlockedReason: status.billingBlockedReason, - blockedByOrgOwner: status.blockedByOrgOwner, - }, - } + const h = await headers() + const cookie = h.get('cookie') + const response = await fetch(`${getInternalApiBaseUrl()}/api/billing?context=user`, { + headers: cookie ? { cookie } : {}, + }) + if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`) + return response.json() }, staleTime: 5 * 60 * 1000, })