mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
committed by
GitHub
parent
2608f2f12c
commit
5e11e5df91
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { usePanelResize } from './use-panel-resize'
|
||||
export { type UsageData, useUsageLimits } from './use-usage-limits'
|
||||
export { useUsageLimits } from './use-usage-limits'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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%)`
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -220,7 +220,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: {},
|
||||
error: 'Child workflow failed',
|
||||
childTraceSpans: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
|
||||
@@ -76,7 +76,6 @@ export function useUpdateWorkspaceSettings() {
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate workspace settings
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.settings(variables.workspaceId),
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user