improvement(sidebar): prefetch workspace list server-side so the switcher never flashes loading (#5706)

This commit is contained in:
Waleed
2026-07-16 01:42:52 -07:00
committed by GitHub
parent f5a6c47f01
commit 2ec72b6c81
11 changed files with 285 additions and 158 deletions
@@ -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
}
+16 -108
View File
@@ -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: () =>
@@ -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) }
}
+7 -30
View File
@@ -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')
+12
View File
@@ -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: {
+128
View File
@@ -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,
}
}
+31 -1
View File
@@ -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