fix(ui): live usage indicator, child trace spans, cancel subscription modal z-index (#2044)

* cleanup

* show trace spans for child blocks that error

* fix z index for cancel subscription popup

* rotating digit live usage indicator

* fix

* remove unused code

* fix type

* fix(billing): fix team upgrade

* fix

* fix tests

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-11-18 20:21:16 -08:00
committed by GitHub
parent 2608f2f12c
commit 5e11e5df91
32 changed files with 497 additions and 806 deletions

View File

@@ -1,278 +0,0 @@
/**
* Tests for Subscription Transfer API
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockAdminMember,
mockDb,
mockLogger,
mockOrganization,
mockRegularMember,
mockSubscription,
mockUser,
} from '@/app/api/__test-utils__/utils'
describe('Subscription Transfer API Routes', () => {
beforeEach(() => {
vi.resetModules()
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: mockUser,
}),
}))
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@sim/db', () => ({
db: mockDb,
}))
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockSubscription]),
})
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ affected: 1 }]),
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('POST handler', () => {
it('should successfully transfer a personal subscription to an organization', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: {
...mockUser,
id: 'user-123',
},
}),
}))
vi.doMock('@sim/db/schema', () => ({
subscription: { id: 'id', referenceId: 'referenceId' },
organization: { id: 'id' },
member: { userId: 'userId', organizationId: 'organizationId', role: 'role' },
}))
const mockSubscriptionWithReferenceId = {
...mockSubscription,
referenceId: 'user-123',
}
mockDb.select.mockImplementation(() => {
return {
from: () => ({
where: () => {
if (mockDb.select.mock.calls.length === 1) {
return Promise.resolve([mockSubscriptionWithReferenceId])
}
if (mockDb.select.mock.calls.length === 2) {
return Promise.resolve([mockOrganization])
}
return Promise.resolve([mockAdminMember])
},
}),
}
})
mockDb.update.mockReturnValue({
set: () => ({
where: () => Promise.resolve({ affected: 1 }),
}),
})
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message', 'Subscription transferred successfully')
expect(mockDb.update).toHaveBeenCalled()
})
it('should test behavior when subscription not found', async () => {
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([]),
})
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
})
it('should test behavior when organization not found', async () => {
const mockSelectImpl = vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockSubscription]),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([]),
})
mockDb.select.mockImplementation(mockSelectImpl)
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
})
it('should reject transfer if user is not the subscription owner', async () => {
const differentOwnerSubscription = {
...mockSubscription,
referenceId: 'different-user-123',
}
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([differentOwnerSubscription]),
})
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should reject non-personal transfer if user is not admin of organization', async () => {
const orgOwnedSubscription = {
...mockSubscription,
referenceId: 'other-org-789',
}
const mockSelectImpl = vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([orgOwnedSubscription]),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockOrganization]),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockRegularMember]),
})
mockDb.select.mockImplementation(mockSelectImpl)
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should reject invalid request parameters', async () => {
const req = createMockRequest('POST', {})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Invalid request parameters')
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should handle authentication error', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should handle internal server error', async () => {
mockDb.select.mockImplementation(() => {
throw new Error('Database error')
})
const req = createMockRequest('POST', {
organizationId: 'org-456',
})
const { POST } = await import('@/app/api/users/me/subscription/[id]/transfer/route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(500)
expect(data).toHaveProperty('error', 'Failed to transfer subscription')
expect(mockLogger.error).toHaveBeenCalled()
})
})
})

View File

@@ -81,9 +81,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.then((rows) => rows[0])
const isPersonalTransfer = sub.referenceId === session.user.id
if (!isPersonalTransfer && (!mem || (mem.role !== 'owner' && mem.role !== 'admin'))) {
if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) {
return NextResponse.json(
{ error: 'Unauthorized - user is not admin of organization' },
{ status: 403 }

View File

@@ -135,18 +135,22 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
.filter(([, v]) => v)
.map(([k]) => k)
)
const filterTree = (spans: TraceSpan[]): TraceSpan[] =>
const filterTree = (spans: TraceSpan[], parentIsWorkflow = false): TraceSpan[] =>
spans
.map((s) => ({ ...s }))
.map((s) => normalizeChildWorkflowSpan(s))
.filter((s) => {
const tl = s.type?.toLowerCase?.() || ''
if (tl === 'workflow') return true
if (parentIsWorkflow) return true
return allowed.has(tl)
})
.map((s) => ({
...s,
children: s.children ? filterTree(s.children) : undefined,
}))
.map((s) => {
const tl = s.type?.toLowerCase?.() || ''
return {
...s,
children: s.children ? filterTree(s.children, tl === 'workflow') : undefined,
}
})
return traceSpans ? filterTree(traceSpans) : []
}, [traceSpans, effectiveTypeFilters])
@@ -181,7 +185,6 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
return () => ro.disconnect()
}, [])
// Early return after all hooks are declared to comply with React's Rules of Hooks
if (!traceSpans || traceSpans.length === 0) {
return <div className='text-muted-foreground text-sm'>No trace data available</div>
}
@@ -217,21 +220,19 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
</div>
<div ref={containerRef} className='relative w-full overflow-hidden border shadow-sm'>
{filtered.map((span, index) => {
const normalizedSpan = normalizeChildWorkflowSpan(span)
const hasSubItems = Boolean(
(normalizedSpan.children && normalizedSpan.children.length > 0) ||
(normalizedSpan.toolCalls && normalizedSpan.toolCalls.length > 0) ||
normalizedSpan.input ||
normalizedSpan.output
(span.children && span.children.length > 0) ||
(span.toolCalls && span.toolCalls.length > 0) ||
span.input ||
span.output
)
// Calculate gap from previous span (for sequential execution visualization)
let gapMs = 0
let gapPercent = 0
if (index > 0) {
const prevSpan = filtered[index - 1]
const prevEndTime = new Date(prevSpan.endTime).getTime()
const currentStartTime = new Date(normalizedSpan.startTime).getTime()
const currentStartTime = new Date(span.startTime).getTime()
gapMs = currentStartTime - prevEndTime
if (gapMs > 0 && actualTotalDuration > 0) {
gapPercent = (gapMs / actualTotalDuration) * 100
@@ -241,13 +242,13 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
return (
<TraceSpanItem
key={index}
span={normalizedSpan}
span={span}
depth={0}
totalDuration={
actualTotalDuration !== undefined ? actualTotalDuration : totalDuration
}
isLast={index === traceSpans.length - 1}
parentStartTime={new Date(normalizedSpan.startTime).getTime()}
parentStartTime={new Date(span.startTime).getTime()}
workflowStartTime={workflowStartTime}
onToggle={handleSpanToggle}
expandedSpans={expandedSpans}

View File

@@ -1,2 +1,2 @@
export { usePanelResize } from './use-panel-resize'
export { type UsageData, useUsageLimits } from './use-usage-limits'
export { useUsageLimits } from './use-usage-limits'

View File

@@ -1,239 +1,23 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import type { UsageData as StoreUsageData } from '@/lib/subscription/types'
const logger = createLogger('useUsageLimits')
import { useSubscriptionData } from '@/hooks/queries/subscription'
/**
* Extended usage data structure that combines API response and store data.
* Supports both 'current' (from store) and 'currentUsage' (from API) for compatibility.
* Simplified hook that uses React Query for usage limits.
* Provides usage exceeded status from existing subscription data.
*/
export interface UsageData {
percentUsed: number
isWarning: boolean
isExceeded: boolean
current: number
currentUsage: number
limit: number
}
/**
* Normalizes usage data to ensure both 'current' and 'currentUsage' fields exist
*/
function normalizeUsageData(data: StoreUsageData | any): UsageData {
return {
percentUsed: data.percentUsed,
isWarning: data.isWarning,
isExceeded: data.isExceeded,
current: data.current ?? data.currentUsage ?? 0,
currentUsage: data.currentUsage ?? data.current ?? 0,
limit: data.limit,
}
}
/**
* Cache for usage data to prevent excessive API calls
*/
let usageDataCache: {
data: UsageData | null
timestamp: number
expirationMs: number
} = {
data: null,
timestamp: 0,
expirationMs: 60 * 1000, // Cache expires after 1 minute
}
/**
* Custom hook to manage usage limits with caching and automatic refresh.
* Provides usage data, exceeded status, and methods to check, refresh, and update limits.
*
* Features:
* - Automatic caching with 60-second expiration
* - Fallback to subscription store if API unavailable
* - Auto-refresh on mount
* - Manual refresh capability
* - Update limit functionality (for user and organization contexts)
* - Integration with usage-limit.tsx component
*
* @param options - Configuration options
* @param options.context - Context for usage check ('user' or 'organization')
* @param options.organizationId - Required when context is 'organization'
* @param options.autoRefresh - Whether to automatically check on mount (default: true)
* @returns Usage state and helper methods
*/
export function useUsageLimits(options?: {
context?: 'user' | 'organization'
organizationId?: string
autoRefresh?: boolean
}) {
const { context = 'user', organizationId, autoRefresh = true } = options || {}
// For now, we only support user context via React Query
// Organization context should use useOrganizationBilling directly
const { data: subscriptionData, isLoading } = useSubscriptionData()
const [usageData, setUsageData] = useState<UsageData | null>(null)
const [usageExceeded, setUsageExceeded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const [error, setError] = useState<Error | null>(null)
/**
* Check user/organization usage limits with caching
*/
const checkUsage = useCallback(
async (forceRefresh = false): Promise<UsageData | null> => {
const now = Date.now()
const cacheAge = now - usageDataCache.timestamp
// Return cached data if still valid and not forcing refresh
if (!forceRefresh && usageDataCache.data && cacheAge < usageDataCache.expirationMs) {
logger.info('Using cached usage data', {
cacheAge: `${Math.round(cacheAge / 1000)}s`,
})
return usageDataCache.data
}
setIsLoading(true)
setError(null)
try {
// Build query params
const params = new URLSearchParams({ context })
if (context === 'organization' && organizationId) {
params.append('organizationId', organizationId)
}
// Primary: call server-side usage check to mirror backend enforcement
const res = await fetch(`/api/usage?${params.toString()}`, { cache: 'no-store' })
if (res.ok) {
const payload = await res.json()
const usage = normalizeUsageData(payload?.data)
// Update cache
usageDataCache = {
data: usage,
timestamp: now,
expirationMs: usageDataCache.expirationMs,
}
setUsageData(usage)
setUsageExceeded(usage?.isExceeded || false)
return usage
}
// No fallback available - React Query handles this globally
throw new Error('Failed to fetch usage data')
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to check usage limits')
logger.error('Error checking usage limits:', { error })
setError(error)
return null
} finally {
setIsLoading(false)
}
},
[context, organizationId]
)
/**
* Update usage limit for user or organization
*/
const updateLimit = useCallback(
async (newLimit: number): Promise<{ success: boolean; error?: string }> => {
setIsUpdating(true)
setError(null)
try {
if (context === 'organization') {
if (!organizationId) {
throw new Error('Organization ID is required')
}
const response = await fetch('/api/usage', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ context: 'organization', organizationId, limit: newLimit }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update limit')
}
// Clear cache and refresh
clearCache()
await checkUsage(true)
return { success: true }
}
// User context - use API directly
const response = await fetch('/api/usage', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ context: 'user', limit: newLimit }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update limit')
}
// Clear cache and refresh
clearCache()
await checkUsage(true)
return { success: true }
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update usage limit'
logger.error('Failed to update usage limit', { error: err })
setError(err instanceof Error ? err : new Error(errorMessage))
return { success: false, error: errorMessage }
} finally {
setIsUpdating(false)
}
},
[context, organizationId, checkUsage]
)
/**
* Refresh usage data, bypassing cache
*/
const refresh = useCallback(async () => {
return checkUsage(true)
}, [checkUsage])
/**
* Clear the cache (useful for testing or forced refresh)
*/
const clearCache = useCallback(() => {
usageDataCache = {
data: null,
timestamp: 0,
expirationMs: usageDataCache.expirationMs,
}
}, [])
/**
* Auto-refresh on mount if enabled
*/
useEffect(() => {
if (autoRefresh) {
checkUsage()
}
}, [autoRefresh, checkUsage])
const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false
return {
usageData,
usageExceeded,
isLoading,
isUpdating,
error,
checkUsage,
refresh,
updateLimit,
clearCache,
}
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { v4 as uuidv4 } from 'uuid'
import { shallow } from 'zustand/shallow'
import { createLogger } from '@/lib/logs/console/logger'
@@ -11,6 +12,7 @@ import {
} from '@/lib/workflows/trigger-utils'
import { resolveStartCandidates, StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useExecutionStore } from '@/stores/execution/store'
@@ -84,6 +86,7 @@ function extractExecutionResult(error: unknown): ExecutionResult | null {
export function useWorkflowExecution() {
const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const queryClient = useQueryClient()
const { toggleConsole, addConsole } = useTerminalConsoleStore()
const { getAllVariables } = useEnvironmentStore()
const { getVariablesByWorkflowId, variables } = useVariablesStore()
@@ -416,7 +419,7 @@ export function useWorkflowExecution() {
if (!streamingExecution.stream) return
const reader = streamingExecution.stream.getReader()
const blockId = (streamingExecution.execution as any)?.blockId
const streamStartTime = Date.now()
let isFirstChunk = true
if (blockId) {
@@ -577,6 +580,10 @@ export function useWorkflowExecution() {
logger.info(`Processed ${processedCount} blocks for streaming tokenization`)
}
// Invalidate subscription query to update usage
queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
const { encodeSSE } = await import('@/lib/utils')
controller.enqueue(encodeSSE({ event: 'final', data: result }))
// Note: Logs are already persisted server-side via execution-core.ts
@@ -639,6 +646,10 @@ export function useWorkflowExecution() {
}
;(result.metadata as any).source = 'chat'
}
// Invalidate subscription query to update usage
queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
}
return result
} catch (error: any) {

View File

@@ -194,7 +194,13 @@ export function SettingsNavigation({
return false
}
if (item.requiresTeam && !hasOrganization) {
if (item.requiresTeam) {
const isMember = userRole === 'member' || isAdmin
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
if (isMember) return true
if (isOwner && hasTeamPlan) return true
return false
}

View File

@@ -5,7 +5,7 @@ import { Check, Pencil, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/hooks'
import { useUpdateOrganizationUsageLimit } from '@/hooks/queries/organization'
import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
const logger = createLogger('UsageLimit')
@@ -41,22 +41,22 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
const [hasError, setHasError] = useState(false)
const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [pendingLimit, setPendingLimit] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { updateLimit, isUpdating: isOrgUpdating } = useUsageLimits({
context,
organizationId,
autoRefresh: false, // Don't auto-refresh, we receive values via props
})
const updateUserLimitMutation = useUpdateUsageLimit()
const updateOrgLimitMutation = useUpdateOrganizationUsageLimit()
const updateUsageLimitMutation = useUpdateUsageLimit()
const isUpdating =
context === 'organization' ? isOrgUpdating : updateUsageLimitMutation.isPending
context === 'organization'
? updateOrgLimitMutation.isPending
: updateUserLimitMutation.isPending
const handleStartEdit = () => {
if (!canEdit) return
setIsEditing(true)
setInputValue(currentLimit.toString())
const displayLimit = pendingLimit !== null ? pendingLimit : currentLimit
setInputValue(displayLimit.toString())
}
useImperativeHandle(
@@ -64,12 +64,19 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
() => ({
startEdit: handleStartEdit,
}),
[canEdit, currentLimit]
[canEdit, currentLimit, pendingLimit]
)
useEffect(() => {
setInputValue(currentLimit.toString())
}, [currentLimit])
if (pendingLimit !== null) {
if (currentLimit === pendingLimit) {
setPendingLimit(null)
setInputValue(currentLimit.toString())
}
} else {
setInputValue(currentLimit.toString())
}
}, [currentLimit, pendingLimit])
useEffect(() => {
if (isEditing && inputRef.current) {
@@ -110,31 +117,19 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
try {
if (context === 'organization') {
const result = await updateLimit(newLimit)
if (result.success) {
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
setErrorType(null)
setHasError(false)
} else {
logger.error('Failed to update usage limit', { error: result.error })
if (result.error?.includes('below current usage')) {
setErrorType('belowUsage')
} else {
setErrorType('general')
}
if (!organizationId) {
logger.error('Organization ID is required for organization context')
setErrorType('general')
setHasError(true)
return
}
return
await updateOrgLimitMutation.mutateAsync({ organizationId, limit: newLimit })
} else {
await updateUserLimitMutation.mutateAsync({ limit: newLimit })
}
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
setPendingLimit(newLimit)
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
@@ -150,13 +145,16 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
setErrorType('general')
}
setPendingLimit(null)
setInputValue(currentLimit.toString())
setHasError(true)
}
}
const handleCancelEdit = () => {
setIsEditing(false)
setInputValue(currentLimit.toString())
const displayLimit = pendingLimit !== null ? pendingLimit : currentLimit
setInputValue(displayLimit.toString())
setHasError(false)
setErrorType(null)
}
@@ -206,7 +204,9 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
/>
</>
) : (
<span className='text-muted-foreground text-xs tabular-nums'>${currentLimit}</span>
<span className='text-muted-foreground text-xs tabular-nums'>
${pendingLimit !== null ? pendingLimit : currentLimit}
</span>
)}
{canEdit && (
<Button

View File

@@ -62,7 +62,7 @@ export function TeamSeats({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='z-[100000000]'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>

View File

@@ -1,10 +1,8 @@
import { useCallback, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useRef } from 'react'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import { useActiveOrganization } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
import { getBaseUrl } from '@/lib/urls/utils'
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header'
@@ -12,11 +10,9 @@ import {
UsageLimit,
type UsageLimitRef,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components'
import { organizationKeys, useOrganizationBilling } from '@/hooks/queries/organization'
import { useOrganizationBilling } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('TeamUsage')
interface TeamUsageProps {
hasAdminAccess: boolean
}
@@ -24,28 +20,14 @@ interface TeamUsageProps {
export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
const { data: activeOrg } = useActiveOrganization()
const { data: subscriptionData } = useSubscriptionData()
const queryClient = useQueryClient()
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
// Fetch organization billing data using React Query
const {
data: billingData,
isLoading: isLoadingOrgBilling,
error,
} = useOrganizationBilling(activeOrg?.id || '')
const handleLimitUpdated = useCallback(
async (newLimit: number) => {
// Invalidate the billing query to refetch with the new limit
if (activeOrg?.id) {
await queryClient.invalidateQueries({
queryKey: organizationKeys.billing(activeOrg.id),
})
}
},
[activeOrg?.id, queryClient]
)
const usageLimitRef = useRef<UsageLimitRef | null>(null)
if (isLoadingOrgBilling) {
@@ -146,7 +128,6 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
minimumLimit={minimumBilling}
context='organization'
organizationId={activeOrg.id}
onLimitUpdated={handleLimitUpdated}
/>
) : (
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>

View File

@@ -0,0 +1,92 @@
'use client'
import { cn } from '@/lib/utils'
export interface RotatingDigitProps {
value: number | string
height?: number
width?: number
className?: string
textClassName?: string
}
/**
* RotatingDigit component for displaying numbers with a rolling animation effect.
* Useful for live-updating metrics like usage, pricing, or counters.
*
* @example
* ```tsx
* <RotatingDigit value={123.45} height={14} width={8} />
* ```
*/
export function RotatingDigit({
value,
height = 14, // Default to match text size
width = 8,
className,
textClassName,
}: RotatingDigitProps) {
const parts =
typeof value === 'number' ? value.toFixed(2).split('') : (value as string).toString().split('')
return (
<div className={cn('flex items-center overflow-hidden', className)} style={{ height }}>
{parts.map((part: string, index: number) => {
if (/[0-9]/.test(part)) {
return (
<SingleDigit
key={`${index}-${parts.length}`} // Key by index and length to reset if length changes
digit={Number.parseInt(part, 10)}
height={height}
width={width}
className={textClassName}
/>
)
}
return (
<div
key={`${index}-${part}`}
className={cn('flex items-center justify-center', textClassName)}
style={{ height, width: width / 2 }}
>
{part}
</div>
)
})}
</div>
)
}
function SingleDigit({
digit,
height,
width,
className,
}: {
digit: number
height: number
width: number
className?: string
}) {
return (
<div className='relative overflow-hidden' style={{ height, width }}>
<div
className='absolute top-0 left-0 flex flex-col will-change-transform'
style={{
transform: `translateY(-${digit * height}px)`,
transition: 'transform 500ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<div
key={num}
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
{num}
</div>
))}
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
@@ -11,8 +12,10 @@ import {
getUsage,
} from '@/lib/subscription/helpers'
import { isUsageAtLimit, USAGE_PILL_COLORS } from '@/lib/subscription/usage-visualization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useSocket } from '@/contexts/socket-context'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
import { RotatingDigit } from './rotating-digit'
const logger = createLogger('UsageIndicator')
@@ -85,6 +88,20 @@ interface UsageIndicatorProps {
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const { data: subscriptionData, isLoading } = useSubscriptionData()
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
const { onOperationConfirmed } = useSocket()
const queryClient = useQueryClient()
// Listen for completed operations to update usage
useEffect(() => {
const handleOperationConfirmed = () => {
// Small delay to ensure backend has updated usage
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
}, 1000)
}
onOperationConfirmed(handleOperationConfirmed)
}, [onOperationConfirmed, queryClient])
/**
* Calculate pill count based on sidebar width (6-8 pills dynamically).
@@ -130,9 +147,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const startAnimationIndex = pillCount === 0 ? 0 : Math.min(filledPillsCount, pillCount - 1)
useEffect(() => {
const isFreePlan = subscription.isFree
if (!isHovered || pillCount <= 0 || !isFreePlan) {
// Animation enabled for all plans on hover
if (!isHovered || pillCount <= 0) {
setWavePosition(null)
return
}
@@ -162,7 +178,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
return () => {
window.clearInterval(interval)
}
}, [isHovered, pillCount, startAnimationIndex, subscription.isFree])
}, [isHovered, pillCount, startAnimationIndex])
if (isLoading) {
return (
@@ -196,7 +212,6 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const blocked = getBillingStatus(subscriptionData?.data) === 'blocked'
const canUpg = canUpgrade(subscriptionData?.data)
// If blocked, try to open billing portal directly for faster recovery
if (blocked) {
try {
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
@@ -224,7 +239,6 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
}
// Fallback: Open Settings modal to the subscription tab
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
logger.info('Opened settings to subscription tab', { blocked, canUpgrade: canUpg })
@@ -245,10 +259,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
>
{/* Top row */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[#FFFFFF] text-[12px]'>{PLAN_NAMES[planType]}</span>
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
<div className='flex items-center gap-[4px]'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
<span className='flex-shrink-0 font-medium text-[#FFFFFF] text-[12px]'>
{PLAN_NAMES[planType]}
</span>
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
{isBlocked ? (
<>
<span className='font-medium text-[12px] text-red-400'>Payment</span>
@@ -256,9 +272,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
</>
) : (
<>
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
${usage.current.toFixed(2)}
</span>
<div className='flex items-center font-medium text-[12px] text-[var(--text-tertiary)]'>
<span className='mr-[1px]'>$</span>
<RotatingDigit
value={usage.current}
height={14}
width={7}
textClassName='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'
/>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>/</span>
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
${usage.limit}
@@ -296,7 +318,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
let backgroundColor = baseColor
let backgroundImage: string | undefined
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
if (isHovered && wavePosition !== null && pillCount > 0) {
const grayColor = USAGE_PILL_COLORS.UNFILLED
const activeColor = isAtLimit ? USAGE_PILL_COLORS.AT_LIMIT : USAGE_PILL_COLORS.FILLED
@@ -311,7 +333,6 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const pillOffsetFromStart = i - startAnimationIndex
if (pillOffsetFromStart < 0) {
// Before the wave start; keep original baseColor.
} else if (pillOffsetFromStart < headIndex) {
backgroundColor = isFilled ? baseColor : grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`

View File

@@ -33,7 +33,7 @@ const AlertDialogOverlay = React.forwardRef<
return (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[10000030] bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[10000150] bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
className
)}
style={{ backdropFilter: 'blur(1.5px)', ...style }}
@@ -87,7 +87,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[10000031] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] px-6 py-5 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[10000151] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] px-6 py-5 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
className
)}
onPointerDown={(e) => {

View File

@@ -199,6 +199,10 @@ export class BlockExecutor {
error: errorMessage,
}
if (error && typeof error === 'object' && 'childTraceSpans' in error) {
errorOutput.childTraceSpans = (error as any).childTraceSpans
}
this.state.setBlockOutput(node.id, errorOutput, duration)
logger.error(

View File

@@ -220,7 +220,9 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({
success: false,
childWorkflowName: 'Child Workflow',
result: {},
error: 'Child workflow failed',
childTraceSpans: [],
})
})

View File

@@ -87,8 +87,6 @@ export class WorkflowBlockHandler implements BlockHandler {
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
// Perform lazy cleanup: remove orphaned fields from inputMapping
// that no longer exist in the child workflow's inputFormat
const cleanedMapping = await lazyCleanupInputMapping(
ctx.workflowId || 'unknown',
block.id,
@@ -115,11 +113,15 @@ export class WorkflowBlockHandler implements BlockHandler {
})
const startTime = performance.now()
const result = await subExecutor.execute(workflowId)
const executionResult = this.toExecutionResult(result)
const duration = performance.now() - startTime
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`)
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`, {
success: executionResult.success,
hasLogs: (executionResult.logs?.length ?? 0) > 0,
})
const childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
@@ -131,17 +133,6 @@ export class WorkflowBlockHandler implements BlockHandler {
childTraceSpans
)
if ((mappedResult as any).success === false) {
const childError = (mappedResult as any).error || 'Unknown error'
const errorWithSpans = new Error(
`Error in child workflow "${childWorkflowName}": ${childError}`
) as any
errorWithSpans.childTraceSpans = childTraceSpans
errorWithSpans.childWorkflowName = childWorkflowName
errorWithSpans.executionResult = executionResult
throw errorWithSpans
}
return mappedResult
} catch (error: any) {
logger.error(`Error executing child workflow ${workflowId}:`, error)
@@ -150,24 +141,43 @@ export class WorkflowBlockHandler implements BlockHandler {
const workflowMetadata = workflows[workflowId]
const childWorkflowName = workflowMetadata?.name || workflowId
const originalError = error.message || 'Unknown error'
if (originalError.startsWith('Error in child workflow')) {
throw error
if (error.executionResult?.logs) {
const executionResult = error.executionResult as ExecutionResult
logger.info(`Extracting child trace spans from error.executionResult`, {
hasLogs: (executionResult.logs?.length ?? 0) > 0,
logCount: executionResult.logs?.length ?? 0,
})
const childTraceSpans = this.captureChildWorkflowLogs(
executionResult,
childWorkflowName,
ctx
)
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
return {
success: false,
childWorkflowName,
result: {},
error: error.message || 'Child workflow execution failed',
childTraceSpans: childTraceSpans,
} as Record<string, any>
}
const wrappedError = new Error(
`Error in child workflow "${childWorkflowName}": ${originalError}`
) as any
if (error.childTraceSpans) {
wrappedError.childTraceSpans = error.childTraceSpans
if (error.childTraceSpans && Array.isArray(error.childTraceSpans)) {
return {
success: false,
childWorkflowName,
result: {},
error: error.message || 'Child workflow execution failed',
childTraceSpans: error.childTraceSpans,
} as Record<string, any>
}
if (error.childWorkflowName) {
wrappedError.childWorkflowName = error.childWorkflowName
}
if (error.executionResult) {
wrappedError.executionResult = error.executionResult
}
throw wrappedError
const originalError = error.message || 'Unknown error'
throw new Error(`Error in child workflow "${childWorkflowName}": ${originalError}`)
}
}
@@ -435,21 +445,21 @@ export class WorkflowBlockHandler implements BlockHandler {
childTraceSpans?: WorkflowTraceSpan[]
): BlockOutput {
const success = childResult.success !== false
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
const failure: Record<string, any> = {
success: false,
childWorkflowName,
error: childResult.error || 'Child workflow execution failed',
}
if (Array.isArray(childTraceSpans) && childTraceSpans.length > 0) {
failure.childTraceSpans = childTraceSpans
}
return failure as Record<string, any>
}
const result = childResult.output || {}
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
// Return failure with child trace spans so they can be displayed
return {
success: false,
childWorkflowName,
result,
error: childResult.error || 'Child workflow execution failed',
childTraceSpans: childTraceSpans || [],
} as Record<string, any>
}
// Success case
return {
success: true,
childWorkflowName,

View File

@@ -56,7 +56,6 @@ async function fetchApiKeys(workspaceId: string): Promise<ApiKeysResponse> {
personalKeys = personalData.keys || []
}
// Client-side conflict detection
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
const conflicts = personalKeys
.filter((key) => workspaceKeyNames.has(key.name))
@@ -71,14 +70,13 @@ async function fetchApiKeys(workspaceId: string): Promise<ApiKeysResponse> {
/**
* Hook to fetch API keys (both workspace and personal)
* API keys change infrequently, cache for 60 seconds
*/
export function useApiKeys(workspaceId: string) {
return useQuery({
queryKey: apiKeysKeys.combined(workspaceId),
queryFn: () => fetchApiKeys(workspaceId),
enabled: !!workspaceId,
staleTime: 60 * 1000, // 60 seconds
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
@@ -119,7 +117,6 @@ export function useCreateApiKey() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate API keys cache
queryClient.invalidateQueries({
queryKey: apiKeysKeys.combined(variables.workspaceId),
})
@@ -161,7 +158,6 @@ export function useDeleteApiKey() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate API keys cache
queryClient.invalidateQueries({
queryKey: apiKeysKeys.combined(variables.workspaceId),
})
@@ -202,7 +198,6 @@ export function useUpdateWorkspaceApiKeySettings() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate workspace settings cache
queryClient.invalidateQueries({
queryKey: workspaceKeys.settings(variables.workspaceId),
})

View File

@@ -47,13 +47,12 @@ async function fetchCopilotKeys(): Promise<CopilotKey[]> {
/**
* Hook to fetch Copilot API keys
* Only fetches when in hosted environment
*/
export function useCopilotKeys() {
return useQuery({
queryKey: copilotKeysKeys.keys(),
queryFn: fetchCopilotKeys,
enabled: isHosted, // Only fetch in hosted environments
enabled: isHosted,
staleTime: 30 * 1000, // 30 seconds
placeholderData: keepPreviousData,
})
@@ -82,11 +81,9 @@ export function useGenerateCopilotKey() {
return response.json()
},
onSuccess: () => {
// Force refetch even if query is disabled (enabled: isHosted check)
// Using refetchQueries ensures it runs regardless of enabled state
queryClient.refetchQueries({
queryKey: copilotKeysKeys.keys(),
type: 'active', // Only refetch if query is currently subscribed/active
type: 'active',
})
},
onError: (error) => {
@@ -119,13 +116,10 @@ export function useDeleteCopilotKey() {
return response.json()
},
onMutate: async ({ keyId }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: copilotKeysKeys.keys() })
// Snapshot the previous value
const previousKeys = queryClient.getQueryData<CopilotKey[]>(copilotKeysKeys.keys())
// Optimistically remove the key from the list
queryClient.setQueryData<CopilotKey[]>(copilotKeysKeys.keys(), (old) => {
return old?.filter((k) => k.id !== keyId) || []
})
@@ -133,14 +127,12 @@ export function useDeleteCopilotKey() {
return { previousKeys }
},
onError: (error, _variables, context) => {
// Rollback to previous value on error
if (context?.previousKeys) {
queryClient.setQueryData(copilotKeysKeys.keys(), context.previousKeys)
}
logger.error('Failed to delete Copilot API key:', error)
},
onSettled: () => {
// Always refetch after error or success to ensure server state
queryClient.invalidateQueries({ queryKey: copilotKeysKeys.keys() })
},
})

View File

@@ -154,12 +154,10 @@ export function useSaveCreatorProfile() {
return result.data
},
onSuccess: (_data, variables) => {
// Invalidate the profile query to refetch
queryClient.invalidateQueries({
queryKey: creatorProfileKeys.profile(variables.referenceId),
})
// Dispatch event to notify that a creator profile was saved
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('creator-profile-saved'))
}

View File

@@ -223,7 +223,6 @@ export function useUpdateCustomTool() {
mutationFn: async ({ workspaceId, toolId, updates }: UpdateCustomToolParams) => {
logger.info(`Updating custom tool: ${toolId} in workspace ${workspaceId}`)
// Get the current tool to merge with updates
const currentTools = queryClient.getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
)
@@ -263,15 +262,12 @@ export function useUpdateCustomTool() {
return data.data
},
onMutate: async ({ workspaceId, toolId, updates }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
// Snapshot the previous value
const previousTools = queryClient.getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
)
// Optimistically update to the new value
if (previousTools) {
queryClient.setQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId),
@@ -291,13 +287,11 @@ export function useUpdateCustomTool() {
return { previousTools }
},
onError: (_err, variables, context) => {
// Rollback on error
if (context?.previousTools) {
queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools)
}
},
onSettled: (_data, _error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
},
})
@@ -338,15 +332,12 @@ export function useDeleteCustomTool() {
onMutate: async ({ workspaceId, toolId }) => {
if (!workspaceId) return
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
// Snapshot the previous value
const previousTools = queryClient.getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
)
// Optimistically update to the new value
if (previousTools) {
queryClient.setQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId),
@@ -357,13 +348,11 @@ export function useDeleteCustomTool() {
return { previousTools, workspaceId }
},
onError: (_err, _variables, context) => {
// Rollback on error
if (context?.previousTools && context?.workspaceId) {
queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools)
}
},
onSettled: (_data, _error, variables) => {
// Always refetch after error or success
if (variables.workspaceId) {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
}

View File

@@ -107,9 +107,7 @@ export function useSavePersonalEnvironment() {
return transformedVariables
},
onSuccess: () => {
// Invalidate personal environment queries
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
// Invalidate all workspace environments as they may have conflicts
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
},
})
@@ -142,11 +140,9 @@ export function useUpsertWorkspaceEnvironment() {
return await response.json()
},
onSuccess: (_data, variables) => {
// Invalidate workspace environment
queryClient.invalidateQueries({
queryKey: environmentKeys.workspace(variables.workspaceId),
})
// Invalidate personal environment as conflicts may have changed
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
},
})
@@ -179,11 +175,9 @@ export function useRemoveWorkspaceEnvironment() {
return await response.json()
},
onSuccess: (_data, variables) => {
// Invalidate workspace environment
queryClient.invalidateQueries({
queryKey: environmentKeys.workspace(variables.workspaceId),
})
// Invalidate personal environment as conflicts may have changed
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
},
})

View File

@@ -150,7 +150,6 @@ export function useDeleteFolderMutation() {
},
onSuccess: async (_data, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
// Invalidate workflow queries to reload workflows after folder changes
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
},
})
@@ -181,7 +180,6 @@ export function useDuplicateFolderMutation() {
},
onSuccess: async (_data, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
// Invalidate workflow queries to reload workflows after folder changes
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
},
})

View File

@@ -121,15 +121,12 @@ export function useUpdateGeneralSetting() {
return response.json()
},
onMutate: async ({ key, value }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: generalSettingsKeys.settings() })
// Snapshot the previous value
const previousSettings = queryClient.getQueryData<GeneralSettings>(
generalSettingsKeys.settings()
)
// Optimistically update to the new value
if (previousSettings) {
const newSettings = {
...previousSettings,
@@ -137,25 +134,20 @@ export function useUpdateGeneralSetting() {
}
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
// Immediately sync to Zustand for optimistic update throughout the app
syncSettingsToZustand(newSettings)
}
return { previousSettings }
},
onError: (err, _variables, context) => {
// Rollback on error
if (context?.previousSettings) {
queryClient.setQueryData(generalSettingsKeys.settings(), context.previousSettings)
// Also rollback Zustand store
syncSettingsToZustand(context.previousSettings)
}
logger.error('Failed to update setting:', err)
},
onSuccess: (_data, _variables, _context) => {
// Invalidate to ensure we have the latest from server
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
// Sync will happen automatically when the query refetches in useGeneralSettings hook
},
})
}

View File

@@ -192,9 +192,7 @@ export function useDeleteMcpServer() {
return data
},
onSuccess: (_data, variables) => {
// Invalidate servers list to refetch
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
// Invalidate tools as deleted server's tools should be removed
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
},
})
@@ -214,19 +212,14 @@ export function useUpdateMcpServer() {
return useMutation({
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
// For now, this is optimistic-only since there's no PATCH endpoint
// The component would need a PATCH endpoint for full implementation
logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`)
return { serverId, updates }
},
onMutate: async ({ workspaceId, serverId, updates }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
// Snapshot the previous value
const previousServers = queryClient.getQueryData<McpServer[]>(mcpKeys.servers(workspaceId))
// Optimistically update to the new value
if (previousServers) {
queryClient.setQueryData<McpServer[]>(
mcpKeys.servers(workspaceId),
@@ -241,13 +234,11 @@ export function useUpdateMcpServer() {
return { previousServers }
},
onError: (_err, variables, context) => {
// Rollback on error
if (context?.previousServers) {
queryClient.setQueryData(mcpKeys.servers(variables.workspaceId), context.previousServers)
}
},
onSettled: (_data, _error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
},
})

View File

@@ -46,13 +46,10 @@ function defineServices(): ServiceInfo[] {
*/
async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
try {
// Start with the base service definitions
const serviceDefinitions = defineServices()
// Fetch all OAuth connections for the user
const response = await fetch('/api/auth/oauth/connections')
// Treat 404 as "no connections"
if (response.status === 404) {
return serviceDefinitions
}
@@ -64,12 +61,9 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
const data = await response.json()
const connections = data.connections || []
// Update services with connection status and account info
const updatedServices = serviceDefinitions.map((service) => {
// Find matching connection - exact match on providerId
const connection = connections.find((conn: any) => conn.provider === service.providerId)
// If we found an exact match, use it
if (connection) {
return {
...service,
@@ -79,14 +73,11 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
}
// If no exact match, check if any connection has all the required scopes
const connectionWithScopes = connections.find((conn: any) => {
// Only consider connections from the same base provider
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
return false
}
// Check if all required scopes for this service are included in the connection
if (conn.scopes && service.scopes) {
return service.scopes.every((scope) => conn.scopes.includes(scope))
}
@@ -109,7 +100,6 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
return updatedServices
} catch (error) {
logger.error('Error fetching OAuth connections:', error)
// Return base definitions on error
return defineServices()
}
}
@@ -140,7 +130,6 @@ export function useConnectOAuthService() {
return useMutation({
mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => {
// Handle Trello specially
if (providerId === 'trello') {
window.location.href = '/api/auth/trello/authorize'
return { success: true }
@@ -154,7 +143,6 @@ export function useConnectOAuthService() {
return { success: true }
},
onSuccess: () => {
// Invalidate connections to refetch
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
},
onError: (error) => {
@@ -196,15 +184,12 @@ export function useDisconnectOAuthService() {
return response.json()
},
onMutate: async ({ serviceId, accountId }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() })
// Snapshot the previous value
const previousServices = queryClient.getQueryData<ServiceInfo[]>(
oauthConnectionsKeys.connections()
)
// Optimistically update by removing the disconnected account
if (previousServices) {
queryClient.setQueryData<ServiceInfo[]>(
oauthConnectionsKeys.connections(),
@@ -225,14 +210,12 @@ export function useDisconnectOAuthService() {
return { previousServices }
},
onError: (_err, _variables, context) => {
// Rollback on error
if (context?.previousServices) {
queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices)
}
logger.error('Failed to disconnect service')
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
},
})

View File

@@ -74,7 +74,6 @@ async function fetchOrganizationSubscription(orgId: string) {
return null
}
// Pass query parameter to filter by referenceId (matches old store behavior)
const response = await client.subscription.list({
query: { referenceId: orgId },
})
@@ -84,7 +83,6 @@ async function fetchOrganizationSubscription(orgId: string) {
return null
}
// Find active team or enterprise subscription (same logic as old store)
const teamSubscription = response.data?.find(
(sub: any) => sub.status === 'active' && sub.plan === 'team'
)
@@ -93,7 +91,6 @@ async function fetchOrganizationSubscription(orgId: string) {
)
const activeSubscription = enterpriseSubscription || teamSubscription
// React Query requires non-undefined return values, use null instead
return activeSubscription || null
}
@@ -105,7 +102,7 @@ export function useOrganizationSubscription(orgId: string) {
queryKey: organizationKeys.subscription(orgId),
queryFn: () => fetchOrganizationSubscription(orgId),
enabled: !!orgId,
retry: false, // Don't retry when no organization exists
retry: false,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
@@ -117,7 +114,6 @@ export function useOrganizationSubscription(orgId: string) {
async function fetchOrganizationBilling(orgId: string) {
const response = await fetch(`/api/billing?context=organization&id=${orgId}`)
// Treat 404 as "no billing data available"
if (response.status === 404) {
return null
}
@@ -136,7 +132,7 @@ export function useOrganizationBilling(orgId: string) {
queryKey: organizationKeys.billing(orgId),
queryFn: () => fetchOrganizationBilling(orgId),
enabled: !!orgId,
retry: false, // Don't retry when no billing data exists
retry: false,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
@@ -148,7 +144,6 @@ export function useOrganizationBilling(orgId: string) {
async function fetchOrganizationMembers(orgId: string) {
const response = await fetch(`/api/organizations/${orgId}/members?include=usage`)
// Treat 404 as "no members found"
if (response.status === 404) {
return { members: [] }
}
@@ -172,6 +167,88 @@ export function useOrganizationMembers(orgId: string) {
})
}
/**
* Update organization usage limit mutation with optimistic updates
*/
interface UpdateOrganizationUsageLimitParams {
organizationId: string
limit: number
}
export function useUpdateOrganizationUsageLimit() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ organizationId, limit }: UpdateOrganizationUsageLimitParams) => {
const response = await fetch('/api/usage', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'organization', organizationId, limit }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || error.error || 'Failed to update usage limit')
}
return response.json()
},
onMutate: async ({ organizationId, limit }) => {
await queryClient.cancelQueries({ queryKey: organizationKeys.billing(organizationId) })
await queryClient.cancelQueries({ queryKey: organizationKeys.subscription(organizationId) })
const previousBillingData = queryClient.getQueryData(organizationKeys.billing(organizationId))
const previousSubscriptionData = queryClient.getQueryData(
organizationKeys.subscription(organizationId)
)
queryClient.setQueryData(organizationKeys.billing(organizationId), (old: any) => {
if (!old) return old
const currentUsage = old.data?.currentUsage || old.data?.usage?.current || 0
const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
return {
...old,
data: {
...old.data,
totalUsageLimit: limit,
usage: {
...old.data?.usage,
limit,
percentUsed: newPercentUsed,
},
percentUsed: newPercentUsed,
},
}
})
return { previousBillingData, previousSubscriptionData, organizationId }
},
onError: (_err, _variables, context) => {
if (context?.previousBillingData && context?.organizationId) {
queryClient.setQueryData(
organizationKeys.billing(context.organizationId),
context.previousBillingData
)
}
if (context?.previousSubscriptionData && context?.organizationId) {
queryClient.setQueryData(
organizationKeys.subscription(context.organizationId),
context.previousSubscriptionData
)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: organizationKeys.billing(variables.organizationId),
})
queryClient.invalidateQueries({
queryKey: organizationKeys.subscription(variables.organizationId),
})
},
})
}
/**
* Invite member mutation
*/
@@ -203,11 +280,9 @@ export function useInviteMember() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
// Also refetch the org list to update counts
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
},
})
@@ -242,7 +317,6 @@ export function useRemoveMember() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
@@ -317,7 +391,6 @@ export function useUpdateSeats() {
return response.data
},
onSuccess: (_data, variables) => {
// Invalidate all related queries
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
@@ -355,7 +428,6 @@ export function useUpdateOrganization() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate organization details
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
},
@@ -384,7 +456,6 @@ export function useCreateOrganization() {
throw new Error('Failed to create organization')
}
// Set as active organization
await client.organization.setActive({
organizationId: response.data.id,
})
@@ -392,7 +463,6 @@ export function useCreateOrganization() {
return response.data
},
onSuccess: () => {
// Refetch all organizations
queryClient.invalidateQueries({ queryKey: organizationKeys.all })
},
})

View File

@@ -22,7 +22,6 @@ async function fetchSSOProviders() {
/**
* Hook to fetch SSO providers
* Cache for 5 minutes since SSO config rarely changes
*/
export function useSSOProviders() {
return useQuery({
@@ -63,10 +62,8 @@ export function useConfigureSSO() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate SSO providers list
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
// Also invalidate organization data if org context
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.detail(variables.orgId),
@@ -104,10 +101,8 @@ export function useDeleteSSO() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate SSO providers list
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
// Also invalidate organization data if org context
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.detail(variables.orgId),

View File

@@ -85,8 +85,53 @@ export function useUpdateUsageLimit() {
return response.json()
},
onSuccess: () => {
// Invalidate all subscription-related queries
onMutate: async ({ limit }) => {
await queryClient.cancelQueries({ queryKey: subscriptionKeys.user() })
await queryClient.cancelQueries({ queryKey: subscriptionKeys.usage() })
const previousSubscriptionData = queryClient.getQueryData(subscriptionKeys.user())
const previousUsageData = queryClient.getQueryData(subscriptionKeys.usage())
queryClient.setQueryData(subscriptionKeys.user(), (old: any) => {
if (!old) return old
const currentUsage = old.data?.usage?.current || 0
const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
return {
...old,
data: {
...old.data,
usage: {
...old.data?.usage,
limit,
percentUsed: newPercentUsed,
},
},
}
})
queryClient.setQueryData(subscriptionKeys.usage(), (old: any) => {
if (!old) return old
return {
...old,
data: {
...old.data,
currentLimit: limit,
},
}
})
return { previousSubscriptionData, previousUsageData }
},
onError: (_err, _variables, context) => {
if (context?.previousSubscriptionData) {
queryClient.setQueryData(subscriptionKeys.user(), context.previousSubscriptionData)
}
if (context?.previousUsageData) {
queryClient.setQueryData(subscriptionKeys.usage(), context.previousUsageData)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
},
@@ -106,15 +151,11 @@ export function useUpgradeSubscription() {
return useMutation({
mutationFn: async ({ plan }: UpgradeSubscriptionParams) => {
// This will be handled by the existing subscription upgrade flow
// We just need to ensure proper cache invalidation
return { plan }
},
onSuccess: (_data, variables) => {
// Invalidate all subscription queries
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
// Also invalidate organization billing if org context
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.billing(variables.orgId),

View File

@@ -84,13 +84,10 @@ export function useUpdateUserProfile() {
return response.json()
},
onMutate: async (updates) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: userProfileKeys.profile() })
// Snapshot the previous value
const previousProfile = queryClient.getQueryData<UserProfile>(userProfileKeys.profile())
// Optimistically update to the new value
if (previousProfile) {
queryClient.setQueryData<UserProfile>(userProfileKeys.profile(), {
...previousProfile,
@@ -102,14 +99,12 @@ export function useUpdateUserProfile() {
return { previousProfile }
},
onError: (err, _variables, context) => {
// Rollback on error
if (context?.previousProfile) {
queryClient.setQueryData(userProfileKeys.profile(), context.previousProfile)
}
logger.error('Failed to update profile:', err)
},
onSuccess: () => {
// Invalidate to ensure we have the latest from server
queryClient.invalidateQueries({ queryKey: userProfileKeys.profile() })
},
})

View File

@@ -59,7 +59,6 @@ export function useWorkspaceFiles(workspaceId: string) {
async function fetchStorageInfo(): Promise<StorageInfo | null> {
const response = await fetch('/api/users/me/usage-limits')
// Treat 404 as "no storage info available"
if (response.status === 404) {
return null
}
@@ -164,17 +163,14 @@ export function useDeleteWorkspaceFile() {
return data
},
onMutate: async ({ workspaceId, fileId, fileSize }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.list(workspaceId) })
await queryClient.cancelQueries({ queryKey: ['storageInfo'] })
// Snapshot the previous values
const previousFiles = queryClient.getQueryData<WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId)
)
const previousStorage = queryClient.getQueryData<StorageInfo>(['storageInfo'])
// Optimistically update files list
if (previousFiles) {
queryClient.setQueryData<WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId),
@@ -182,7 +178,6 @@ export function useDeleteWorkspaceFile() {
)
}
// Optimistically update storage info
if (previousStorage) {
const newUsedBytes = Math.max(0, previousStorage.usedBytes - fileSize)
const newPercentUsed = (newUsedBytes / previousStorage.limitBytes) * 100
@@ -196,7 +191,6 @@ export function useDeleteWorkspaceFile() {
return { previousFiles, previousStorage }
},
onError: (_err, variables, context) => {
// Rollback on error
if (context?.previousFiles) {
queryClient.setQueryData(
workspaceFilesKeys.list(variables.workspaceId),
@@ -209,7 +203,6 @@ export function useDeleteWorkspaceFile() {
logger.error('Failed to delete file')
},
onSettled: (_data, _error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: ['storageInfo'] })
},

View File

@@ -76,7 +76,6 @@ export function useUpdateWorkspaceSettings() {
return response.json()
},
onSuccess: (_data, variables) => {
// Invalidate workspace settings
queryClient.invalidateQueries({
queryKey: workspaceKeys.settings(variables.workspaceId),
})

View File

@@ -43,85 +43,83 @@ export function useSubscriptionUpgrade() {
// For team plans, create organization first and use its ID as referenceId
if (targetPlan === 'team') {
try {
logger.info('Creating organization for team plan upgrade', {
userId,
})
// Check if user already has an organization where they are owner/admin
const orgsResponse = await fetch('/api/organizations')
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json()
const existingOrg = orgsData.organizations?.find(
(org: any) => org.role === 'owner' || org.role === 'admin'
)
const response = await fetch('/api/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to create organization: ${response.statusText}`)
if (existingOrg) {
logger.info('Using existing organization for team plan upgrade', {
userId,
organizationId: existingOrg.id,
})
referenceId = existingOrg.id
}
}
const result = await response.json()
// Only create new organization if no suitable one exists
if (referenceId === userId) {
logger.info('Creating organization for team plan upgrade', {
userId,
})
logger.info('Organization API response', {
result,
success: result.success,
organizationId: result.organizationId,
})
const response = await fetch('/api/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (!result.success || !result.organizationId) {
throw new Error('Failed to create organization for team plan')
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
if (response.status === 409) {
throw new Error(
'You are already a member of an organization. Please leave it or ask an admin to upgrade.'
)
}
throw new Error(
errorData.message || `Failed to create organization: ${response.statusText}`
)
}
const result = await response.json()
logger.info('Organization API response', {
result,
success: result.success,
organizationId: result.organizationId,
})
if (!result.success || !result.organizationId) {
throw new Error('Failed to create organization for team plan')
}
referenceId = result.organizationId
}
referenceId = result.organizationId
// Set the organization as active so Better Auth recognizes it
try {
await client.organization.setActive({ organizationId: result.organizationId })
await client.organization.setActive({ organizationId: referenceId })
logger.info('Set organization as active', {
organizationId: result.organizationId,
organizationId: referenceId,
oldReferenceId: userId,
newReferenceId: referenceId,
})
} catch (error) {
logger.warn('Failed to set organization as active, but proceeding with upgrade', {
organizationId: result.organizationId,
organizationId: referenceId,
error: error instanceof Error ? error.message : 'Unknown error',
})
// Continue with upgrade even if setting active fails
}
if (currentSubscriptionId) {
logger.info('Transferring personal subscription to organization', {
subscriptionId: currentSubscriptionId,
organizationId: referenceId,
})
const transferResponse = await fetch(
`/api/users/me/subscription/${currentSubscriptionId}/transfer`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: referenceId }),
}
)
if (!transferResponse.ok) {
const text = await transferResponse.text()
logger.error('Failed to transfer subscription to organization', {
subscriptionId: currentSubscriptionId,
organizationId: referenceId,
error: text,
})
throw new Error(text || 'Failed to transfer subscription to organization')
}
logger.info('Successfully transferred subscription to organization', {
subscriptionId: currentSubscriptionId,
organizationId: referenceId,
})
}
} catch (error) {
logger.error('Failed to create organization for team plan', error)
throw new Error('Failed to create team workspace. Please try again or contact support.')
logger.error('Failed to prepare organization for team plan', error)
throw error instanceof Error
? error
: new Error('Failed to prepare team workspace. Please try again or contact support.')
}
}
@@ -152,6 +150,42 @@ export function useSubscriptionUpgrade() {
await betterAuthSubscription.upgrade(finalParams)
// If upgrading to team plan, ensure the subscription is transferred to the organization
if (targetPlan === 'team' && currentSubscriptionId && referenceId !== userId) {
try {
logger.info('Transferring subscription to organization after upgrade', {
subscriptionId: currentSubscriptionId,
organizationId: referenceId,
})
const transferResponse = await fetch(
`/api/users/me/subscription/${currentSubscriptionId}/transfer`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: referenceId }),
}
)
if (!transferResponse.ok) {
const text = await transferResponse.text()
logger.error('Failed to transfer subscription to organization', {
subscriptionId: currentSubscriptionId,
organizationId: referenceId,
error: text,
})
// We don't throw here because the upgrade itself succeeded
} else {
logger.info('Successfully transferred subscription to organization', {
subscriptionId: currentSubscriptionId,
organizationId: referenceId,
})
}
} catch (error) {
logger.error('Error transferring subscription after upgrade', error)
}
}
// For team plans, refresh organization data to ensure UI updates
if (targetPlan === 'team') {
try {