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 3dc8aadf665..ddc004a8724 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -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 ( @@ -30,7 +46,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..9eebb7f56ea --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -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 { + 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 { + 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, + }), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index d04d9481d1a..0df3a17358e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -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> { - 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, diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 01a4bf01a6c..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) @@ -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, }) } 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) +}