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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 3 additions & 30 deletions apps/sim/app/api/mothership/chats/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 2 additions & 11 deletions apps/sim/app/api/users/me/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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 })
Expand Down
75 changes: 4 additions & 71 deletions apps/sim/app/api/users/me/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})

Expand Down
65 changes: 2 additions & 63 deletions apps/sim/app/api/workflows/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 20 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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>
Expand All @@ -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>
Expand Down
57 changes: 57 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
@@ -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)
},
Comment thread
waleedlatif1 marked this conversation as resolved.
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,
}),
])
}
Loading
Loading