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:
Waleed
2026-06-16 19:28:51 -07:00
committed by GitHub
parent a82b44d36d
commit 83531452e5
11 changed files with 362 additions and 212 deletions
+3 -30
View File
@@ -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')
+2 -11
View File
@@ -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 })
+4 -71
View File
@@ -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 })
}
})
+2 -63
View File
@@ -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,
+5 -2
View File
@@ -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,
}))
}
+96
View File
@@ -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
}
+101
View File
@@ -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)
}