mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(sidebar): prefetch workspace list server-side so the switcher never flashes loading (#5706)
This commit is contained in:
@@ -8,6 +8,7 @@ import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
type AppSession,
|
||||
extractSessionDataFromAuthClientResult,
|
||||
getActiveOrganizationId,
|
||||
} from '@/lib/auth/session-response'
|
||||
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
|
||||
|
||||
@@ -71,7 +72,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
|
||||
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
|
||||
const activeOrganizationId = getActiveOrganizationId(session)
|
||||
if (!session || activeOrganizationId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
|
||||
import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
@@ -9,24 +9,20 @@ import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
|
||||
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { PlanCategory } from '@/lib/billing/plan-helpers'
|
||||
import { getActiveOrganizationId } from '@/lib/auth/session-response'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
|
||||
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
|
||||
import {
|
||||
CONTACT_OWNER_TO_UPGRADE_REASON,
|
||||
evaluateWorkspaceInvitePolicy,
|
||||
getInvitePlanCategoryForOrganization,
|
||||
getInvitePlanCategoryForUser,
|
||||
getWorkspaceCreationPolicy,
|
||||
getWorkspaceInvitePolicy,
|
||||
UPGRADE_TO_INVITE_REASON,
|
||||
resolveInviteFlags,
|
||||
WORKSPACE_MODE,
|
||||
} from '@/lib/workspaces/policy'
|
||||
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
|
||||
|
||||
const logger = createLogger('Workspaces')
|
||||
|
||||
@@ -38,13 +34,6 @@ export const GET = withRouteHandler(async (request: Request) => {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const activeOrganizationId =
|
||||
(session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null
|
||||
const creationPolicy = await getWorkspaceCreationPolicy({
|
||||
userId: session.user.id,
|
||||
activeOrganizationId,
|
||||
})
|
||||
|
||||
const scopeResult = listWorkspacesQuerySchema.safeParse(
|
||||
Object.fromEntries(new URL(request.url).searchParams.entries())
|
||||
)
|
||||
@@ -56,20 +45,15 @@ export const GET = withRouteHandler(async (request: Request) => {
|
||||
}
|
||||
const { scope } = scopeResult.data
|
||||
|
||||
const settingsQuery = db
|
||||
.select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.limit(1)
|
||||
const activeOrganizationId = getActiveOrganizationId(session)
|
||||
const payload = await listWorkspacesForViewer({
|
||||
userId: session.user.id,
|
||||
activeOrganizationId,
|
||||
scope,
|
||||
})
|
||||
const { lastActiveWorkspaceId, creationPolicy } = payload
|
||||
|
||||
const [userWorkspaces, userSettings] = await Promise.all([
|
||||
listAccessibleWorkspaceRowsForUser(session.user.id, scope),
|
||||
settingsQuery,
|
||||
])
|
||||
|
||||
const lastActiveWorkspaceId = userSettings[0]?.lastActiveWorkspaceId ?? null
|
||||
|
||||
if (scope === 'active' && userWorkspaces.length === 0) {
|
||||
if (scope === 'active' && payload.workspaces.length === 0) {
|
||||
if (!creationPolicy.canCreate) {
|
||||
return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy })
|
||||
}
|
||||
@@ -95,76 +79,10 @@ export const GET = withRouteHandler(async (request: Request) => {
|
||||
}
|
||||
|
||||
if (scope === 'active') {
|
||||
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
|
||||
await ensureWorkflowsHaveWorkspace(session.user.id, payload.workspaces[0].id)
|
||||
}
|
||||
|
||||
const nonOrgBilledUserIds = [
|
||||
...new Set(
|
||||
userWorkspaces
|
||||
.filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
|
||||
.map(({ workspace: ws }) => ws.billedAccountUserId)
|
||||
),
|
||||
]
|
||||
const orgIds = [
|
||||
...new Set(
|
||||
userWorkspaces
|
||||
.filter(
|
||||
({ workspace: ws }) =>
|
||||
ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId
|
||||
)
|
||||
.map(({ workspace: ws }) => ws.organizationId as string)
|
||||
),
|
||||
]
|
||||
const planCategoryByBilledUser = new Map<string, PlanCategory>()
|
||||
const planCategoryByOrg = new Map<string, PlanCategory>()
|
||||
await Promise.all([
|
||||
...nonOrgBilledUserIds.map(async (userId) => {
|
||||
planCategoryByBilledUser.set(userId, await getInvitePlanCategoryForUser(userId))
|
||||
}),
|
||||
...orgIds.map(async (orgId) => {
|
||||
planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId))
|
||||
}),
|
||||
])
|
||||
|
||||
const workspacesWithPermissions = userWorkspaces.map(
|
||||
({ workspace: workspaceDetails, permissionType }) => {
|
||||
const billedPlanCategory: PlanCategory =
|
||||
workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION
|
||||
? workspaceDetails.organizationId
|
||||
? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free')
|
||||
: 'free'
|
||||
: (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free')
|
||||
const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory })
|
||||
const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id
|
||||
|
||||
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
|
||||
const inviteDisabledReason = invitePolicy.allowed
|
||||
? null
|
||||
: callerIsBilledUser
|
||||
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
|
||||
: CONTACT_OWNER_TO_UPGRADE_REASON
|
||||
|
||||
return {
|
||||
...workspaceDetails,
|
||||
role:
|
||||
workspaceDetails.ownerId === session.user.id
|
||||
? 'owner'
|
||||
: permissionType === 'admin'
|
||||
? 'admin'
|
||||
: 'member',
|
||||
permissions: permissionType,
|
||||
inviteMembersEnabled: invitePolicy.allowed,
|
||||
inviteDisabledReason,
|
||||
inviteUpgradeRequired: canActOnUpgrade,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
workspaces: workspacesWithPermissions,
|
||||
lastActiveWorkspaceId,
|
||||
creationPolicy,
|
||||
})
|
||||
return NextResponse.json(payload)
|
||||
})
|
||||
|
||||
// POST /api/workspaces - Create a new workspace
|
||||
@@ -179,8 +97,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const parsed = await parseRequest(createWorkspaceContract, req, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, color, skipDefaultWorkflow } = parsed.data.body
|
||||
const activeOrganizationId =
|
||||
(session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null
|
||||
const activeOrganizationId = getActiveOrganizationId(session)
|
||||
const creationPolicy = await getWorkspaceCreationPolicy({
|
||||
userId: session.user.id,
|
||||
activeOrganizationId,
|
||||
@@ -380,13 +297,6 @@ async function createWorkspace({
|
||||
billedAccountUserId,
|
||||
ownerId: userId,
|
||||
})
|
||||
const callerIsBilledUser = billedAccountUserId === userId
|
||||
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
|
||||
const inviteDisabledReason = invitePolicy.allowed
|
||||
? null
|
||||
: callerIsBilledUser
|
||||
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
|
||||
: CONTACT_OWNER_TO_UPGRADE_REASON
|
||||
|
||||
return {
|
||||
id: workspaceId,
|
||||
@@ -401,9 +311,7 @@ async function createWorkspace({
|
||||
updatedAt: now,
|
||||
role: 'owner',
|
||||
permissions: 'admin',
|
||||
inviteMembersEnabled: invitePolicy.allowed,
|
||||
inviteDisabledReason,
|
||||
inviteUpgradeRequired: canActOnUpgrade,
|
||||
...resolveInviteFlags(invitePolicy, billedAccountUserId === userId),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,8 @@ describe('WorkspaceLayout host context', () => {
|
||||
expect.anything(),
|
||||
'workspace-b',
|
||||
'viewer-1',
|
||||
HOST_CONTEXT
|
||||
HOST_CONTEXT,
|
||||
'org-a'
|
||||
)
|
||||
expect(mockBrandingProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getActiveOrganizationId } from '@/lib/auth/session-response'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import {
|
||||
ImpersonationBanner,
|
||||
@@ -44,12 +45,19 @@ export default async function WorkspaceLayout({
|
||||
return <WorkspaceAccessDenied />
|
||||
}
|
||||
|
||||
const activeOrganizationId = getActiveOrganizationId(session)
|
||||
const [cookieStore, initialOrgSettings] = await Promise.all([
|
||||
cookies(),
|
||||
hostContext.hostOrganizationId
|
||||
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
|
||||
: Promise.resolve(null),
|
||||
prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id, hostContext),
|
||||
prefetchWorkspaceSidebar(
|
||||
queryClient,
|
||||
workspaceId,
|
||||
session.user.id,
|
||||
hostContext,
|
||||
activeOrganizationId
|
||||
),
|
||||
])
|
||||
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
|
||||
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
|
||||
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
|
||||
import { listFoldersForWorkspace } from '@/lib/folders/queries'
|
||||
import { listWorkflowsForUser } from '@/lib/workflows/queries'
|
||||
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
|
||||
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
|
||||
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
|
||||
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
|
||||
import {
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
|
||||
import {
|
||||
normalizeWorkspacesResponse,
|
||||
WORKSPACE_LIST_STALE_TIME,
|
||||
} from '@/hooks/queries/utils/workspace-list-query'
|
||||
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
|
||||
import {
|
||||
WORKSPACE_HOST_CONTEXT_STALE_TIME,
|
||||
@@ -37,22 +42,27 @@ export function prefetchWorkspaceHostContext(
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetches the sidebar's workflow, chat, folder, and workspace-permissions 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.
|
||||
* Prefetches the sidebar's workflow, chat, folder, workspace-permissions, and
|
||||
* workspace lists for a workspace and stores them under the same query keys +
|
||||
* mappers the client hooks use, so the persistent sidebar (including the
|
||||
* workspace switcher header) 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.
|
||||
*
|
||||
* The host context is the authorization proof for this server-render pass, so
|
||||
* permission prefetch can reuse its effective permission without repeating
|
||||
* workspace and membership reads.
|
||||
* workspace and membership reads. It also proves the viewer has at least one
|
||||
* accessible workspace, which is why the workspace-list prefetch can safely
|
||||
* skip the route's empty-list default-workspace creation path — and the
|
||||
* route's orphaned-workflow repair, which still runs on client refetches.
|
||||
*/
|
||||
export async function prefetchWorkspaceSidebar(
|
||||
queryClient: QueryClient,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
hostContext: WorkspaceHostContext
|
||||
hostContext: WorkspaceHostContext,
|
||||
activeOrganizationId: string | null
|
||||
): Promise<void> {
|
||||
if (hostContext.workspace.id !== workspaceId) return
|
||||
await Promise.all([
|
||||
@@ -80,6 +90,27 @@ export async function prefetchWorkspaceSidebar(
|
||||
},
|
||||
staleTime: FOLDER_LIST_STALE_TIME,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: workspaceKeys.list('active'),
|
||||
queryFn: async () => {
|
||||
const payload = await listWorkspacesForViewer({
|
||||
userId,
|
||||
activeOrganizationId,
|
||||
scope: 'active',
|
||||
})
|
||||
// An empty list means GET /api/workspaces' default-workspace creation
|
||||
// path must run — throw so prefetchQuery caches nothing and the client
|
||||
// fetch reaches the route.
|
||||
if (payload.workspaces.length === 0) {
|
||||
throw new Error('Empty workspace list requires the route creation path')
|
||||
}
|
||||
// Parsing through the route contract's response schema strips the same
|
||||
// server-only fields `requestJson` strips on the client, guaranteeing the
|
||||
// cached shape is identical to a client fetch.
|
||||
return normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload))
|
||||
},
|
||||
staleTime: WORKSPACE_LIST_STALE_TIME,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: workspaceKeys.permissions(workspaceId),
|
||||
queryFn: () =>
|
||||
|
||||
+11
-7
@@ -34,6 +34,15 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
|
||||
const logger = createLogger('WorkspaceHeader')
|
||||
|
||||
/**
|
||||
* Derives the single-letter avatar initial for a workspace, ignoring the word
|
||||
* "workspace" in the name (e.g. "Acme Workspace" → "A").
|
||||
*/
|
||||
function getWorkspaceInitial(name: string | undefined): string {
|
||||
const stripped = (name ?? '').replace(/workspace/gi, '').trim()
|
||||
return (stripped[0] || name?.[0] || 'W').toUpperCase()
|
||||
}
|
||||
|
||||
interface DisabledReasonTooltipProps {
|
||||
reason: string | null
|
||||
children: ReactElement
|
||||
@@ -182,11 +191,7 @@ function WorkspaceHeaderImpl({
|
||||
}
|
||||
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])
|
||||
|
||||
const workspaceInitial = (() => {
|
||||
const name = activeWorkspace?.name || ''
|
||||
const stripped = name.replace(/workspace/gi, '').trim()
|
||||
return (stripped[0] || name[0] || 'W').toUpperCase()
|
||||
})()
|
||||
const workspaceInitial = getWorkspaceInitial(activeWorkspace?.name)
|
||||
|
||||
/**
|
||||
* Opens the context menu for a workspace at the specified position
|
||||
@@ -418,8 +423,7 @@ function WorkspaceHeaderImpl({
|
||||
<>
|
||||
<div className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'>
|
||||
{workspaces.map((workspace) => {
|
||||
const stripped = workspace.name.replace(/workspace/gi, '').trim()
|
||||
const initial = (stripped[0] || workspace.name[0] || 'W').toUpperCase()
|
||||
const initial = getWorkspaceInitial(workspace.name)
|
||||
const isActive = workspace.id === workspaceId
|
||||
const isMenuOpen = menuOpenWorkspaceId === workspace.id
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Workspace, WorkspacesResponse } from '@/lib/api/contracts'
|
||||
|
||||
export const WORKSPACE_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
/**
|
||||
* Applies cached-shape defaults to a single schema-parsed wire workspace —
|
||||
* only the invite fields are optional on the wire; everything else is
|
||||
* guaranteed by the contract schema.
|
||||
*/
|
||||
export function normalizeWorkspace(workspace: Workspace): Workspace {
|
||||
return {
|
||||
...workspace,
|
||||
inviteMembersEnabled: workspace.inviteMembersEnabled ?? false,
|
||||
inviteDisabledReason: workspace.inviteDisabledReason ?? null,
|
||||
inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the schema-parsed /api/workspaces payload into the cached shape.
|
||||
* Shared by the client workspace-list fetch and the workspace layout's
|
||||
* server-side sidebar prefetch so the two can never cache different shapes
|
||||
* under `workspaceKeys.list`.
|
||||
*/
|
||||
export function normalizeWorkspacesResponse(data: WorkspacesResponse): WorkspacesResponse {
|
||||
return { ...data, workspaces: data.workspaces.map(normalizeWorkspace) }
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
type WorkspaceQueryScope,
|
||||
type WorkspacesResponse,
|
||||
} from '@/lib/api/contracts'
|
||||
import {
|
||||
normalizeWorkspace,
|
||||
normalizeWorkspacesResponse,
|
||||
WORKSPACE_LIST_STALE_TIME,
|
||||
} from '@/hooks/queries/utils/workspace-list-query'
|
||||
|
||||
/**
|
||||
* Query key factory for workspace-related queries.
|
||||
@@ -40,7 +45,6 @@ export const workspaceKeys = {
|
||||
export type { Workspace, WorkspaceCreationPolicy, WorkspaceMember, WorkspacePermissions }
|
||||
|
||||
export const WORKSPACE_PERMISSIONS_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_LIST_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_SETTINGS_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_MEMBERS_STALE_TIME = 5 * 60 * 1000
|
||||
export const WORKSPACE_ADMIN_LIST_STALE_TIME = 60 * 1000
|
||||
@@ -50,27 +54,7 @@ async function fetchWorkspaces(
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspacesResponse> {
|
||||
const data = await requestJson(listWorkspacesContract, { query: { scope }, signal })
|
||||
return {
|
||||
workspaces:
|
||||
data.workspaces?.map((workspace: Workspace) => ({
|
||||
...workspace,
|
||||
organizationId: workspace.organizationId ?? null,
|
||||
workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared',
|
||||
inviteMembersEnabled: workspace.inviteMembersEnabled ?? false,
|
||||
inviteDisabledReason: workspace.inviteDisabledReason ?? null,
|
||||
inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false,
|
||||
})) || [],
|
||||
lastActiveWorkspaceId:
|
||||
typeof data.lastActiveWorkspaceId === 'string' ? data.lastActiveWorkspaceId : null,
|
||||
creationPolicy: data.creationPolicy
|
||||
? {
|
||||
...data.creationPolicy,
|
||||
organizationId: data.creationPolicy.organizationId ?? null,
|
||||
reason: data.creationPolicy.reason ?? null,
|
||||
workspaceMode: data.creationPolicy.workspaceMode ?? 'personal',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
return normalizeWorkspacesResponse(data)
|
||||
}
|
||||
|
||||
const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces
|
||||
@@ -338,14 +322,7 @@ async function fetchAdminWorkspaces(
|
||||
}
|
||||
|
||||
const workspacesData = await requestJson(listWorkspacesContract, { query: {}, signal })
|
||||
const allUserWorkspaces = (workspacesData.workspaces || []).map((workspace: Workspace) => ({
|
||||
...workspace,
|
||||
organizationId: workspace.organizationId ?? null,
|
||||
workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared',
|
||||
inviteMembersEnabled: workspace.inviteMembersEnabled ?? false,
|
||||
inviteDisabledReason: workspace.inviteDisabledReason ?? null,
|
||||
inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false,
|
||||
}))
|
||||
const allUserWorkspaces = workspacesData.workspaces.map(normalizeWorkspace)
|
||||
|
||||
return allUserWorkspaces
|
||||
.filter((workspace: Workspace) => workspace.permissions === 'admin')
|
||||
|
||||
@@ -25,6 +25,18 @@ export type AppSession = {
|
||||
}
|
||||
} | null
|
||||
|
||||
/**
|
||||
* Reads the organization plugin's `activeOrganizationId` off a session object
|
||||
* (server `getSession()` result or client {@link AppSession}). Better Auth's
|
||||
* inferred server session type does not declare the field, so this is the one
|
||||
* place the untyped read happens.
|
||||
*/
|
||||
export function getActiveOrganizationId(session: unknown): string | null {
|
||||
if (!isRecordLike(session) || !isRecordLike(session.session)) return null
|
||||
const value = session.session.activeOrganizationId
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
interface BetterAuthErrorEnvelope {
|
||||
data: null
|
||||
error: {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, type workspace as workspaceTable } from '@sim/db/schema'
|
||||
import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { PlanCategory } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
evaluateWorkspaceInvitePolicy,
|
||||
getInvitePlanCategoryForOrganization,
|
||||
getInvitePlanCategoryForUser,
|
||||
getWorkspaceCreationPolicy,
|
||||
resolveInviteFlags,
|
||||
WORKSPACE_MODE,
|
||||
type WorkspaceCreationPolicy,
|
||||
type WorkspaceInviteFlags,
|
||||
} from '@/lib/workspaces/policy'
|
||||
import { listAccessibleWorkspaceRowsForUser, type WorkspaceScope } from '@/lib/workspaces/utils'
|
||||
|
||||
type WorkspaceRow = typeof workspaceTable.$inferSelect
|
||||
|
||||
/** Accessible workspace row decorated with the viewer's role and invite policy flags. */
|
||||
export type WorkspaceWithInviteFlags = WorkspaceRow &
|
||||
WorkspaceInviteFlags & {
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
permissions: PermissionType
|
||||
}
|
||||
|
||||
/** The GET /api/workspaces payload assembled by {@link listWorkspacesForViewer}. */
|
||||
export interface WorkspaceListPayload {
|
||||
workspaces: WorkspaceWithInviteFlags[]
|
||||
lastActiveWorkspaceId: string | null
|
||||
creationPolicy: WorkspaceCreationPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates accessible workspace rows with the viewer's role and per-workspace
|
||||
* invite policy flags (resolving each workspace's billed plan category once per
|
||||
* billed user / organization).
|
||||
*/
|
||||
async function buildWorkspacesWithInviteFlags(
|
||||
userWorkspaces: Array<{ workspace: WorkspaceRow; permissionType: PermissionType }>,
|
||||
userId: string
|
||||
): Promise<WorkspaceWithInviteFlags[]> {
|
||||
const nonOrgBilledUserIds = [
|
||||
...new Set(
|
||||
userWorkspaces
|
||||
.filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
|
||||
.map(({ workspace: ws }) => ws.billedAccountUserId)
|
||||
),
|
||||
]
|
||||
const orgIds = [
|
||||
...new Set(
|
||||
userWorkspaces
|
||||
.filter(
|
||||
({ workspace: ws }) =>
|
||||
ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId
|
||||
)
|
||||
.map(({ workspace: ws }) => ws.organizationId as string)
|
||||
),
|
||||
]
|
||||
const planCategoryByBilledUser = new Map<string, PlanCategory>()
|
||||
const planCategoryByOrg = new Map<string, PlanCategory>()
|
||||
await Promise.all([
|
||||
...nonOrgBilledUserIds.map(async (billedUserId) => {
|
||||
planCategoryByBilledUser.set(billedUserId, await getInvitePlanCategoryForUser(billedUserId))
|
||||
}),
|
||||
...orgIds.map(async (orgId) => {
|
||||
planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId))
|
||||
}),
|
||||
])
|
||||
|
||||
return userWorkspaces.map(({ workspace: workspaceDetails, permissionType }) => {
|
||||
const billedPlanCategory: PlanCategory =
|
||||
workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION
|
||||
? workspaceDetails.organizationId
|
||||
? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free')
|
||||
: 'free'
|
||||
: (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free')
|
||||
const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory })
|
||||
|
||||
return {
|
||||
...workspaceDetails,
|
||||
role:
|
||||
workspaceDetails.ownerId === userId
|
||||
? ('owner' as const)
|
||||
: permissionType === 'admin'
|
||||
? ('admin' as const)
|
||||
: ('member' as const),
|
||||
permissions: permissionType,
|
||||
...resolveInviteFlags(invitePolicy, workspaceDetails.billedAccountUserId === userId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only assembly of the GET /api/workspaces payload for a viewer: accessible
|
||||
* workspaces with role/invite flags, the viewer's last active workspace id, and
|
||||
* the workspace creation policy.
|
||||
*
|
||||
* Unlike the route, this performs no writes — no default-workspace creation and
|
||||
* no orphaned-workflow repair. It exists for the workspace layout's sidebar
|
||||
* prefetch, which only runs after host-context authorization has proven the
|
||||
* viewer already has at least one accessible workspace.
|
||||
*/
|
||||
export async function listWorkspacesForViewer(params: {
|
||||
userId: string
|
||||
activeOrganizationId: string | null
|
||||
scope?: WorkspaceScope
|
||||
}): Promise<WorkspaceListPayload> {
|
||||
const { userId, activeOrganizationId, scope = 'active' } = params
|
||||
|
||||
const [creationPolicy, workspaces, userSettings] = await Promise.all([
|
||||
getWorkspaceCreationPolicy({ userId, activeOrganizationId }),
|
||||
listAccessibleWorkspaceRowsForUser(userId, scope).then((rows) =>
|
||||
buildWorkspacesWithInviteFlags(rows, userId)
|
||||
),
|
||||
db
|
||||
.select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, userId))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
lastActiveWorkspaceId: userSettings[0]?.lastActiveWorkspaceId ?? null,
|
||||
creationPolicy,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import type { PlanCategory } from '@/lib/billing/plan-helpers'
|
||||
import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
|
||||
import {
|
||||
CONTACT_OWNER_TO_UPGRADE_REASON,
|
||||
UPGRADE_TO_INVITE_REASON,
|
||||
} from '@/lib/workspaces/policy-constants'
|
||||
|
||||
const logger = createLogger('WorkspacePolicy')
|
||||
|
||||
@@ -40,6 +43,33 @@ export interface WorkspaceInvitePolicy {
|
||||
upgradeRequired: boolean
|
||||
}
|
||||
|
||||
/** Caller-facing invite flags derived from an evaluated invite policy. */
|
||||
export interface WorkspaceInviteFlags {
|
||||
inviteMembersEnabled: boolean
|
||||
inviteDisabledReason: string | null
|
||||
inviteUpgradeRequired: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the caller-facing invite flags for a workspace response. Only the
|
||||
* billed user can act on an upgrade, so everyone else gets the contact-owner
|
||||
* message when invites are disabled.
|
||||
*/
|
||||
export function resolveInviteFlags(
|
||||
invitePolicy: WorkspaceInvitePolicy,
|
||||
callerIsBilledUser: boolean
|
||||
): WorkspaceInviteFlags {
|
||||
return {
|
||||
inviteMembersEnabled: invitePolicy.allowed,
|
||||
inviteDisabledReason: invitePolicy.allowed
|
||||
? null
|
||||
: callerIsBilledUser
|
||||
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
|
||||
: CONTACT_OWNER_TO_UPGRADE_REASON,
|
||||
inviteUpgradeRequired: invitePolicy.upgradeRequired && callerIsBilledUser,
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceCreationPolicy {
|
||||
canCreate: boolean
|
||||
workspaceMode: WorkspaceMode
|
||||
|
||||
Reference in New Issue
Block a user