mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(sidebar): prefetch chats + workflows so cold loads don't flash skeletons (#5104)
* 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. * 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. * 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. * 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.
This commit is contained in:
@@ -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 data = await listMothershipChats(userId, workspaceId)
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: reconciled })
|
||||
return NextResponse.json({ success: true, data })
|
||||
} catch (error) {
|
||||
if (isWorkspaceAccessDeniedError(error)) {
|
||||
return createForbiddenResponse('Workspace access denied')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 { 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'
|
||||
@@ -11,15 +14,28 @@ 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 = 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
|
||||
const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null
|
||||
|
||||
await sidebarPrefetch
|
||||
|
||||
return (
|
||||
<BrandingProvider initialOrgSettings={initialOrgSettings}>
|
||||
<ToastProvider>
|
||||
@@ -30,7 +46,9 @@ export default async function WorkspaceLayout({ children }: { children: React.Re
|
||||
<ImpersonationBanner />
|
||||
<WorkspacePermissionsProvider>
|
||||
<WorkspaceScopeSync />
|
||||
<WorkspaceChrome>{children}</WorkspaceChrome>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<WorkspaceChrome>{children}</WorkspaceChrome>
|
||||
</HydrationBoundary>
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 {
|
||||
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'
|
||||
|
||||
/** Resolves whether the user may access the workspace, swallowing errors to a `false`. */
|
||||
async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
return access.exists && access.hasAccess
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async function prefetchWorkspaceSidebar(
|
||||
queryClient: QueryClient,
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
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: MOTHERSHIP_CHAT_LIST_STALE_TIME,
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { headers } from 'next/headers'
|
||||
import { getSession } from '@/lib/auth'
|
||||
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'
|
||||
import { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile'
|
||||
|
||||
/**
|
||||
* Forwards incoming request cookies so server-side API fetches authenticate correctly.
|
||||
*/
|
||||
async function getForwardedHeaders(): Promise<Record<string, string>> {
|
||||
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 +16,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,19 +25,23 @@ export function prefetchGeneralSettings(queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch subscription data server-side via internal API fetch.
|
||||
* 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 fwdHeaders = await getForwardedHeaders()
|
||||
const baseUrl = getInternalApiBaseUrl()
|
||||
const response = await fetch(`${baseUrl}/api/billing?context=user`, {
|
||||
headers: fwdHeaders,
|
||||
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()
|
||||
@@ -59,7 +51,7 @@ export function prefetchSubscriptionData(queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +59,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,
|
||||
|
||||
@@ -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)
|
||||
@@ -183,7 +186,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,
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MothershipChat[]> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -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<UserSettingsApi> {
|
||||
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
|
||||
}
|
||||
@@ -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<typeof eq> | ReturnType<typeof inArray>
|
||||
) {
|
||||
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<WorkflowListItem[]> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user