Compare commits

...

6 Commits

Author SHA1 Message Date
Vikhyath Mondreti
afead54c2e improvement(rate-limits): increase across all plans 2026-01-30 19:59:26 -08:00
Vikhyath Mondreti
cf2f1abcaf fix(executor): condition inside parallel (#3094)
* fix(executor): condition inside parallel

* remove comments
2026-01-30 18:47:39 -08:00
Waleed
4109feecf6 feat(invitations): added invitations query hook, migrated all tool files to use absolute imports (#3092)
* feat(invitations): added invitations query hook, migrated all tool files to use absolute imports

* ack PR comments

* remove dead import

* remove unused hook
2026-01-30 18:39:23 -08:00
Waleed
37d5e01f5f fix(mcp): increase timeout from 1m to 10m (#3093) 2026-01-30 17:51:05 -08:00
Vikhyath Mondreti
2d799b3272 fix(billing): plan should be detected from stripe subscription object (#3090)
* fix(billing): plan should be detected from stripe subscription object

* fix typing
2026-01-30 17:01:16 -08:00
Waleed
92403e0594 fix(editor): advanced toggle respects user edit permissions (#3089) 2026-01-30 15:22:46 -08:00
288 changed files with 2004 additions and 1602 deletions

View File

@@ -27,16 +27,16 @@ All API responses include information about your workflow execution limits and u
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 60, // Sustained rate limit per minute
"maxBurst": 120, // Maximum burst capacity
"remaining": 118, // Current tokens available (up to maxBurst)
"resetAt": "..." // When tokens next refill
"requestsPerMinute": 150, // Sustained rate limit per minute
"maxBurst": 300, // Maximum burst capacity
"remaining": 298, // Current tokens available (up to maxBurst)
"resetAt": "..." // When tokens next refill
},
"async": {
"requestsPerMinute": 200, // Sustained rate limit per minute
"maxBurst": 400, // Maximum burst capacity
"remaining": 398, // Current tokens available
"resetAt": "..." // When tokens next refill
"requestsPerMinute": 1000, // Sustained rate limit per minute
"maxBurst": 2000, // Maximum burst capacity
"remaining": 1998, // Current tokens available
"resetAt": "..." // When tokens next refill
}
},
"usage": {
@@ -107,28 +107,28 @@ Query workflow execution logs with extensive filtering options.
}
],
"nextCursor": "eyJzIjoiMjAyNS0wMS0wMVQxMjozNDo1Ni43ODlaIiwiaWQiOiJsb2dfYWJjMTIzIn0",
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 60,
"maxBurst": 120,
"remaining": 118,
"resetAt": "2025-01-01T12:35:56.789Z"
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 150,
"maxBurst": 300,
"remaining": 298,
"resetAt": "2025-01-01T12:35:56.789Z"
},
"async": {
"requestsPerMinute": 1000,
"maxBurst": 2000,
"remaining": 1998,
"resetAt": "2025-01-01T12:35:56.789Z"
}
},
"async": {
"requestsPerMinute": 200,
"maxBurst": 400,
"remaining": 398,
"resetAt": "2025-01-01T12:35:56.789Z"
"usage": {
"currentPeriodCost": 1.234,
"limit": 10,
"plan": "pro",
"isExceeded": false
}
},
"usage": {
"currentPeriodCost": 1.234,
"limit": 10,
"plan": "pro",
"isExceeded": false
}
}
}
```
</Tab>
@@ -188,15 +188,15 @@ Retrieve detailed information about a specific log entry.
"limits": {
"workflowExecutionRateLimit": {
"sync": {
"requestsPerMinute": 60,
"maxBurst": 120,
"remaining": 118,
"requestsPerMinute": 150,
"maxBurst": 300,
"remaining": 298,
"resetAt": "2025-01-01T12:35:56.789Z"
},
"async": {
"requestsPerMinute": 200,
"maxBurst": 400,
"remaining": 398,
"requestsPerMinute": 1000,
"maxBurst": 2000,
"remaining": 1998,
"resetAt": "2025-01-01T12:35:56.789Z"
}
},
@@ -477,10 +477,10 @@ The API uses a **token bucket algorithm** for rate limiting, providing fair usag
| Plan | Requests/Minute | Burst Capacity |
|------|-----------------|----------------|
| Free | 10 | 20 |
| Pro | 30 | 60 |
| Team | 60 | 120 |
| Enterprise | 120 | 240 |
| Free | 30 | 60 |
| Pro | 100 | 200 |
| Team | 200 | 400 |
| Enterprise | 500 | 1000 |
**How it works:**
- Tokens refill at `requestsPerMinute` rate

View File

@@ -170,16 +170,16 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt
"rateLimit": {
"sync": {
"isLimited": false,
"requestsPerMinute": 25,
"maxBurst": 50,
"remaining": 50,
"requestsPerMinute": 150,
"maxBurst": 300,
"remaining": 300,
"resetAt": "2025-09-08T22:51:55.999Z"
},
"async": {
"isLimited": false,
"requestsPerMinute": 200,
"maxBurst": 400,
"remaining": 400,
"requestsPerMinute": 1000,
"maxBurst": 2000,
"remaining": 2000,
"resetAt": "2025-09-08T22:51:56.155Z"
},
"authType": "api"
@@ -206,11 +206,11 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt
Different subscription plans have different usage limits:
| Plan | Monthly Usage Limit | Rate Limits (per minute) |
|------|-------------------|-------------------------|
| **Free** | $20 | 5 sync, 10 async |
| **Pro** | $100 | 10 sync, 50 async |
| **Team** | $500 (pooled) | 50 sync, 100 async |
| Plan | Monthly Usage Included | Rate Limits (per minute) |
|------|------------------------|-------------------------|
| **Free** | $20 | 50 sync, 200 async |
| **Pro** | $20 (adjustable) | 150 sync, 1,000 async |
| **Team** | $40/seat (pooled, adjustable) | 300 sync, 2,500 async |
| **Enterprise** | Custom | Custom |
## Billing Model

View File

@@ -264,7 +264,7 @@ async function handleToolsCall(
method: 'POST',
headers,
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
signal: AbortSignal.timeout(300000), // 5 minute timeout
signal: AbortSignal.timeout(600000), // 10 minute timeout
})
const executeResult = await response.json()

View File

@@ -32,8 +32,7 @@ import {
useTestNotification,
useUpdateNotification,
} from '@/hooks/queries/notifications'
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
import { useConnectedAccounts, useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector'
@@ -167,7 +166,8 @@ export function NotificationSettings({
const deleteNotification = useDeleteNotification()
const testNotification = useTestNotification()
const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts()
const { data: slackAccounts = [], isLoading: isLoadingSlackAccounts } =
useConnectedAccounts('slack')
const connectSlack = useConnectOAuthService()
useEffect(() => {
@@ -530,7 +530,7 @@ export function NotificationSettings({
message:
result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'),
})
} catch (error) {
} catch (_error) {
setTestStatus({ id, success: false, message: 'Failed to send test' })
}
}

View File

@@ -1,32 +1,28 @@
'use client'
import type React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import {
useWorkspacePermissions,
useWorkspacePermissionsQuery,
type WorkspacePermissions,
} from '@/hooks/use-workspace-permissions'
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useNotificationStore } from '@/stores/notifications'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
interface WorkspacePermissionsContextType {
// Raw workspace permissions data
workspacePermissions: WorkspacePermissions | null
permissionsLoading: boolean
permissionsError: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetchPermissions: () => Promise<void>
// Computed user permissions (connection-aware)
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
// Connection state management
setOfflineMode: (isOffline: boolean) => void
}
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType>({
@@ -43,7 +39,6 @@ const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextTyp
isLoading: false,
error: null,
},
setOfflineMode: () => {},
})
interface WorkspacePermissionsProviderProps {
@@ -51,35 +46,20 @@ interface WorkspacePermissionsProviderProps {
}
/**
* Provider that manages workspace permissions and user access
* Also provides connection-aware permissions that enforce read-only mode when offline
* Provides workspace permissions and connection-aware user access throughout the app.
* Enforces read-only mode when offline to prevent data loss.
*/
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const queryClient = useQueryClient()
// Manage offline mode state locally
const [isOfflineMode, setIsOfflineMode] = useState(false)
// Track whether we've already surfaced an offline notification to avoid duplicates
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const addNotification = useNotificationStore((state) => state.addNotification)
// Set offline mode when there are operation errors
useEffect(() => {
if (hasOperationError) {
setIsOfflineMode(true)
}
}, [hasOperationError])
const isOfflineMode = hasOperationError
/**
* Surface a global notification when entering offline mode.
* Uses the shared notifications system instead of bespoke UI in individual components.
*/
useEffect(() => {
if (!isOfflineMode || hasShownOfflineNotification) {
return
@@ -89,7 +69,6 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
addNotification({
level: 'error',
message: 'Connection unavailable',
// Global notification (no workflowId) so it is visible regardless of the active workflow
action: {
type: 'refresh',
message: '',
@@ -101,40 +80,44 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
}
}, [addNotification, hasShownOfflineNotification, isOfflineMode])
// Fetch workspace permissions and loading state
const {
permissions: workspacePermissions,
loading: permissionsLoading,
error: permissionsError,
updatePermissions,
refetch: refetchPermissions,
} = useWorkspacePermissions(workspaceId)
data: workspacePermissions,
isLoading: permissionsLoading,
error: permissionsErrorObj,
refetch,
} = useWorkspacePermissionsQuery(workspaceId)
const permissionsError = permissionsErrorObj?.message ?? null
const updatePermissions = useCallback(
(newPermissions: WorkspacePermissions) => {
if (!workspaceId) return
queryClient.setQueryData(workspaceKeys.permissions(workspaceId), newPermissions)
},
[workspaceId, queryClient]
)
const refetchPermissions = useCallback(async () => {
await refetch()
}, [refetch])
// Get base user permissions from workspace permissions
const baseUserPermissions = useUserPermissions(
workspacePermissions,
workspacePermissions ?? null,
permissionsLoading,
permissionsError
)
// Note: Connection-based error detection removed - only rely on operation timeouts
// The 5-second operation timeout system will handle all error cases
// Create connection-aware permissions that override user permissions when offline
const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
if (isOfflineMode) {
// In offline mode, force read-only permissions regardless of actual user permissions
return {
...baseUserPermissions,
canEdit: false,
canAdmin: false,
// Keep canRead true so users can still view content
canRead: baseUserPermissions.canRead,
isOfflineMode: true,
}
}
// When online, use normal permissions
return {
...baseUserPermissions,
isOfflineMode: false,
@@ -143,13 +126,12 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
const contextValue = useMemo(
() => ({
workspacePermissions,
workspacePermissions: workspacePermissions ?? null,
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
setOfflineMode: setIsOfflineMode,
}),
[
workspacePermissions,
@@ -169,8 +151,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
}
/**
* Hook to access workspace permissions and data from context
* This provides both raw workspace permissions and computed user permissions
* Accesses workspace permissions data and operations from context.
* Must be used within a WorkspacePermissionsProvider.
*/
export function useWorkspacePermissionsContext(): WorkspacePermissionsContextType {
const context = useContext(WorkspacePermissionsContext)
@@ -183,8 +165,8 @@ export function useWorkspacePermissionsContext(): WorkspacePermissionsContextTyp
}
/**
* Hook to access user permissions from context
* This replaces individual useUserPermissions calls and includes connection-aware permissions
* Accesses the current user's computed permissions including offline mode status.
* Convenience hook that extracts userPermissions from the context.
*/
export function useUserPermissionsContext(): WorkspaceUserPermissions & {
isOfflineMode?: boolean

View File

@@ -150,7 +150,9 @@ export function Editor() {
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = advancedMode || advancedValuesPresent
const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
: advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {

View File

@@ -21,14 +21,13 @@ import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useDeleteWorkspaceFile,
useStorageInfo,
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
import { useUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
const logger = createLogger('FileUploadsSettings')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -94,9 +93,7 @@ export function Files() {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { permissions: workspacePermissions, loading: permissionsLoading } =
useWorkspacePermissions(workspaceId)
const userPermissions = useUserPermissions(workspacePermissions, permissionsLoading)
const { userPermissions, permissionsLoading } = useWorkspacePermissionsContext()
const handleUploadClick = () => {
fileInputRef.current?.click()

View File

@@ -13,8 +13,8 @@ import { SlackMonoIcon } from '@/components/icons'
import type { PlanFeature } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card'
export const PRO_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '25 runs per minute (sync)' },
{ icon: Clock, text: '200 runs per minute (async)' },
{ icon: Zap, text: '150 runs per minute (sync)' },
{ icon: Clock, text: '1,000 runs per minute (async)' },
{ icon: HardDrive, text: '50GB file storage' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Users, text: 'Unlimited invites' },
@@ -22,8 +22,8 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [
]
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '75 runs per minute (sync)' },
{ icon: Clock, text: '500 runs per minute (async)' },
{ icon: Zap, text: '300 runs per minute (sync)' },
{ icon: Clock, text: '2,500 runs per minute (async)' },
{ icon: HardDrive, text: '500GB file storage (pooled)' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Users, text: 'Unlimited invites' },

View File

@@ -1,23 +0,0 @@
import React from 'react'
import { Skeleton } from '@/components/ui/skeleton'
export const PermissionsTableSkeleton = React.memo(() => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>
</div>
))
PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton'

View File

@@ -1,20 +1,39 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth/auth-client'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions'
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
import { PermissionSelector } from './permission-selector'
import { PermissionsTableSkeleton } from './permissions-table-skeleton'
import type { UserPermissions } from './types'
const PermissionsTableSkeleton = () => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>
</div>
)
export interface PermissionsTableProps {
userPermissions: UserPermissions[]
onPermissionChange: (userId: string, permissionType: PermissionType) => void
onRemoveMember?: (userId: string, email: string) => void
onRemoveInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string) => void
disabled?: boolean
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
isSaving?: boolean
@@ -143,7 +162,6 @@ export const PermissionsTable = ({
<div>
{allUsers.map((user) => {
const isCurrentUser = user.isCurrentUser === true
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
const isPendingInvitation = user.isPendingInvitation === true
const userIdentifier = user.userId || user.email
const originalPermission = workspacePermissions?.users?.find(
@@ -205,7 +223,7 @@ export const PermissionsTable = ({
<span className='inline-flex'>
<Button
variant='ghost'
onClick={() => onResendInvitation(user.invitationId!, user.email)}
onClick={() => onResendInvitation(user.invitationId!)}
disabled={
disabled ||
isSaving ||

View File

@@ -1,5 +1,4 @@
export { PermissionSelector } from './components/permission-selector'
export { PermissionsTable } from './components/permissions-table'
export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
export type { PermissionType, UserPermissions } from './components/types'
export { InviteModal } from './invite-modal'

View File

@@ -19,7 +19,14 @@ import { useSession } from '@/lib/auth/auth-client'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
import { API_ENDPOINTS } from '@/stores/constants'
import {
useBatchSendWorkspaceInvitations,
useCancelWorkspaceInvitation,
usePendingInvitations,
useRemoveWorkspaceMember,
useResendWorkspaceInvitation,
useUpdateWorkspacePermissions,
} from '@/hooks/queries/invitations'
import type { PermissionType, UserPermissions } from './components/types'
const logger = createLogger('InviteModal')
@@ -30,40 +37,25 @@ interface InviteModalProps {
workspaceName?: string
}
interface PendingInvitation {
id: string
workspaceId: string
email: string
permissions: PermissionType
status: string
createdAt: string
}
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
const [existingUserPermissionChanges, setExistingUserPermissionChanges] = useState<
Record<string, Partial<UserPermissions>>
>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const cooldownIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; email: string } | null>(
null
)
const [isRemovingMember, setIsRemovingMember] = useState(false)
const [invitationToRemove, setInvitationToRemove] = useState<{
invitationId: string
email: string
} | null>(null)
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -72,50 +64,26 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
workspacePermissions,
permissionsLoading,
updatePermissions,
refetchPermissions,
userPermissions: userPerms,
} = useWorkspacePermissionsContext()
const { data: pendingInvitations = [], isLoading: isPendingInvitationsLoading } =
usePendingInvitations(open ? workspaceId : undefined)
const batchSendInvitations = useBatchSendWorkspaceInvitations()
const cancelInvitation = useCancelWorkspaceInvitation()
const resendInvitation = useResendWorkspaceInvitation()
const removeMember = useRemoveWorkspaceMember()
const updatePermissionsMutation = useUpdateWorkspacePermissions()
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
const hasNewInvites = validEmails.length > 0
const fetchPendingInvitations = useCallback(async () => {
if (!workspaceId) return
setIsPendingInvitationsLoading(true)
try {
const response = await fetch('/api/workspaces/invitations')
if (response.ok) {
const data = await response.json()
const workspacePendingInvitations =
data.invitations
?.filter(
(inv: PendingInvitation) =>
inv.status === 'pending' && inv.workspaceId === workspaceId
)
.map((inv: PendingInvitation) => ({
email: inv.email,
permissionType: inv.permissions,
isPendingInvitation: true,
invitationId: inv.id,
})) || []
setPendingInvitations(workspacePendingInvitations)
}
} catch (error) {
logger.error('Error fetching pending invitations:', error)
} finally {
setIsPendingInvitationsLoading(false)
}
}, [workspaceId])
useEffect(() => {
if (open && workspaceId) {
fetchPendingInvitations()
refetchPermissions()
}
}, [open, workspaceId, fetchPendingInvitations, refetchPermissions])
const isSubmitting = batchSendInvitations.isPending
const isSaving = updatePermissionsMutation.isPending
const isRemovingMember = removeMember.isPending
const isRemovingInvitation = cancelInvitation.isPending
useEffect(() => {
if (open) {
@@ -180,16 +148,12 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[emailItems, pendingInvitations, workspacePermissions?.users, session?.user?.email]
)
const removeEmailItem = useCallback(
(_value: string, index: number, isValid?: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid ?? itemToRemove?.isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== itemToRemove?.value))
}
},
[emailItems]
)
const removeEmailItem = useCallback((value: string, index: number, isValid?: boolean) => {
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== value))
}
}, [])
const fileInputOptions: FileInputOptions = useMemo(
() => ({
@@ -198,7 +162,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
extractValues: (text: string) => {
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
const matches = text.match(emailRegex) || []
return [...new Set(matches.map((e) => e.toLowerCase()))]
const uniqueEmails = [...new Set(matches.map((e) => e.toLowerCase()))]
return uniqueEmails.filter((email) => quickValidateEmail(email).isValid)
},
tooltip: 'Upload emails',
}),
@@ -230,53 +195,38 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[workspacePermissions?.users]
)
const handleSaveChanges = useCallback(async () => {
const handleSaveChanges = useCallback(() => {
if (!userPerms.canAdmin || !hasPendingChanges || !workspaceId) return
setIsSaving(true)
setErrorMessage(null)
try {
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
userId,
permissions: changes.permissionType || 'read',
}))
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
userId,
permissions: (changes.permissionType || 'read') as 'admin' | 'write' | 'read',
}))
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(workspaceId), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
updatePermissionsMutation.mutate(
{ workspaceId, updates },
{
onSuccess: (data) => {
if (data.users && data.total !== undefined) {
updatePermissions({ users: data.users, total: data.total })
}
setExistingUserPermissionChanges({})
},
onError: (error) => {
logger.error('Error saving permission changes:', error)
setErrorMessage(error.message || 'Failed to save permission changes. Please try again.')
},
body: JSON.stringify({ updates }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update permissions')
}
if (data.users && data.total !== undefined) {
updatePermissions({ users: data.users, total: data.total })
}
setExistingUserPermissionChanges({})
} catch (error) {
logger.error('Error saving permission changes:', error)
const errorMsg =
error instanceof Error
? error.message
: 'Failed to save permission changes. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsSaving(false)
}
)
}, [
userPerms.canAdmin,
hasPendingChanges,
workspaceId,
existingUserPermissionChanges,
updatePermissions,
updatePermissionsMutation,
])
const handleRestoreChanges = useCallback(() => {
@@ -289,62 +239,57 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setMemberToRemove({ userId, email })
}, [])
const handleRemoveMemberConfirm = useCallback(async () => {
const handleRemoveMemberConfirm = useCallback(() => {
if (!memberToRemove || !workspaceId || !userPerms.canAdmin) return
setIsRemovingMember(true)
setErrorMessage(null)
try {
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
if (!userRecord) {
throw new Error('User is not a member of this workspace')
}
const response = await fetch(`/api/workspaces/members/${memberToRemove.userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to remove member')
}
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
)
updatePermissions({
users: updatedUsers,
total: workspacePermissions.total - 1,
})
}
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
return updated
})
} catch (error) {
logger.error('Error removing member:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to remove member. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsRemovingMember(false)
if (!userRecord) {
setErrorMessage('User is not a member of this workspace')
setMemberToRemove(null)
return
}
}, [memberToRemove, workspaceId, userPerms.canAdmin, workspacePermissions, updatePermissions])
removeMember.mutate(
{ userId: memberToRemove.userId, workspaceId },
{
onSuccess: () => {
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
)
updatePermissions({
users: updatedUsers,
total: workspacePermissions.total - 1,
})
}
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
return updated
})
setMemberToRemove(null)
},
onError: (error) => {
logger.error('Error removing member:', error)
setErrorMessage(error.message || 'Failed to remove member. Please try again.')
setMemberToRemove(null)
},
}
)
}, [
memberToRemove,
workspaceId,
userPerms.canAdmin,
workspacePermissions,
updatePermissions,
removeMember,
])
const handleRemoveMemberCancel = useCallback(() => {
setMemberToRemove(null)
@@ -354,120 +299,101 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvitationToRemove({ invitationId, email })
}, [])
const handleRemoveInvitationConfirm = useCallback(async () => {
const handleRemoveInvitationConfirm = useCallback(() => {
if (!invitationToRemove || !workspaceId || !userPerms.canAdmin) return
setIsRemovingInvitation(true)
setErrorMessage(null)
try {
const response = await fetch(
`/api/workspaces/invitations/${invitationToRemove.invitationId}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
}
)
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to cancel invitation')
cancelInvitation.mutate(
{ invitationId: invitationToRemove.invitationId, workspaceId },
{
onSuccess: () => {
setInvitationToRemove(null)
},
onError: (error) => {
logger.error('Error cancelling invitation:', error)
setErrorMessage(error.message || 'Failed to cancel invitation. Please try again.')
setInvitationToRemove(null)
},
}
setPendingInvitations((prev) =>
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
)
} catch (error) {
logger.error('Error cancelling invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to cancel invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsRemovingInvitation(false)
setInvitationToRemove(null)
}
}, [invitationToRemove, workspaceId, userPerms.canAdmin])
)
}, [invitationToRemove, workspaceId, userPerms.canAdmin, cancelInvitation])
const handleRemoveInvitationCancel = useCallback(() => {
setInvitationToRemove(null)
}, [])
const handleResendInvitation = useCallback(
async (invitationId: string, email: string) => {
(invitationId: string) => {
if (!workspaceId || !userPerms.canAdmin) return
const secondsLeft = resendCooldowns[invitationId]
if (secondsLeft && secondsLeft > 0) return
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
if (resendingInvitationIds[invitationId]) return
setErrorMessage(null)
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
try {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to resend invitation')
}
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
} catch (error) {
logger.error('Error resending invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
if (existingInterval) {
clearInterval(existingInterval)
}
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
resendInvitation.mutate(
{ invitationId, workspaceId },
{
onSuccess: () => {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
cooldownIntervalsRef.current.delete(invitationId)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
})
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
cooldownIntervalsRef.current.set(invitationId, interval)
}
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
if (existingInterval) {
clearInterval(existingInterval)
}
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
cooldownIntervalsRef.current.delete(invitationId)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
cooldownIntervalsRef.current.set(invitationId, interval)
},
onError: (error) => {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
logger.error('Error resending invitation:', error)
setErrorMessage(error.message || 'Failed to resend invitation. Please try again.')
},
}
)
},
[workspaceId, userPerms.canAdmin, resendCooldowns]
[workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation]
)
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
(e: React.FormEvent) => {
e.preventDefault()
setErrorMessage(null)
@@ -476,122 +402,65 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
return
}
setIsSubmitting(true)
try {
const failedInvites: string[] = []
const results = await Promise.all(
validEmails.map(async (email) => {
try {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
const response = await fetch('/api/workspaces/invitations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId,
email: email,
role: 'member',
permission: permissionType,
}),
})
const data = await response.json()
if (!response.ok) {
failedInvites.push(email)
if (data.error) {
setErrorMessage(data.error)
}
return false
}
return true
} catch {
failedInvites.push(email)
return false
}
})
)
const successCount = results.filter(Boolean).length
const successfulEmails = validEmails.filter((_, index) => results[index])
if (successCount > 0) {
if (successfulEmails.length > 0) {
const newPendingInvitations: UserPermissions[] = successfulEmails.map((email) => {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
return {
email,
permissionType,
isPendingInvitation: true,
}
})
setPendingInvitations((prev) => {
const existingEmails = new Set(prev.map((inv) => inv.email))
const merged = [...prev]
newPendingInvitations.forEach((inv) => {
if (!existingEmails.has(inv.email)) {
merged.push(inv)
}
})
return merged
})
}
fetchPendingInvitations()
if (failedInvites.length > 0) {
setEmailItems(failedInvites.map((email) => ({ value: email, isValid: true })))
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
} else {
setEmailItems([])
setUserPermissions([])
}
const invitations = validEmails.map((email) => {
const userPermission = userPermissions.find((up) => up.email === email)
return {
email,
permission: (userPermission?.permissionType || 'read') as 'admin' | 'write' | 'read',
}
} catch (err) {
logger.error('Error inviting members:', err)
const errorMessage =
err instanceof Error ? err.message : 'An unexpected error occurred. Please try again.'
setErrorMessage(errorMessage)
} finally {
setIsSubmitting(false)
}
})
batchSendInvitations.mutate(
{ workspaceId, invitations },
{
onSuccess: (result) => {
if (result.failed.length > 0) {
setEmailItems(result.failed.map((f) => ({ value: f.email, isValid: true })))
setUserPermissions((prev) =>
prev.filter((user) => result.failed.some((f) => f.email === user.email))
)
setErrorMessage(result.failed[0].error)
} else {
setEmailItems([])
setUserPermissions([])
}
},
onError: (error) => {
logger.error('Error inviting members:', error)
setErrorMessage(error.message || 'An unexpected error occurred. Please try again.')
},
}
)
},
[validEmails, workspaceId, userPermissions, fetchPendingInvitations]
[validEmails, workspaceId, userPermissions, batchSendInvitations]
)
const resetState = useCallback(() => {
setEmailItems([])
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
setExistingUserPermissionChanges({})
setIsSubmitting(false)
setIsSaving(false)
setErrorMessage(null)
setMemberToRemove(null)
setIsRemovingMember(false)
setInvitationToRemove(null)
setIsRemovingInvitation(false)
setResendCooldowns({})
setResentInvitationIds({})
setResendingInvitationIds({})
cooldownIntervalsRef.current.forEach((interval) => clearInterval(interval))
cooldownIntervalsRef.current.clear()
}, [])
const pendingInvitationsForTable: UserPermissions[] = useMemo(
() =>
pendingInvitations.map((inv) => ({
email: inv.email,
permissionType: inv.permissionType,
isPendingInvitation: true,
invitationId: inv.invitationId,
})),
[pendingInvitations]
)
return (
<Modal
open={open}
@@ -681,7 +550,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
pendingInvitations={pendingInvitationsForTable}
isPendingInvitationsLoading={isPendingInvitationsLoading}
resendingInvitationIds={resendingInvitationIds}
resentInvitationIds={resentInvitationIds}
@@ -691,26 +560,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</ModalBody>
<ModalFooter className='justify-between'>
{hasPendingChanges && userPerms.canAdmin && (
<div className='flex gap-[8px]'>
<Button
type='button'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
>
Restore Changes
</Button>
<Button
type='button'
variant='tertiary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
<div
className={`flex gap-[8px] ${hasPendingChanges && userPerms.canAdmin ? '' : 'pointer-events-none invisible'}`}
aria-hidden={!(hasPendingChanges && userPerms.canAdmin)}
>
<Button
type='button'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
>
Restore Changes
</Button>
<Button
type='button'
variant='tertiary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
<Button
type='button'

View File

@@ -14,4 +14,4 @@ export {
export { useSidebarResize } from './use-sidebar-resize'
export { useWorkflowOperations } from './use-workflow-operations'
export { useWorkflowSelection } from './use-workflow-selection'
export { useWorkspaceManagement } from './use-workspace-management'
export { useWorkspaceManagement, type Workspace } from './use-workspace-management'

View File

@@ -1,31 +1,33 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { generateWorkspaceName } from '@/lib/workspaces/naming'
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
import {
useCreateWorkspace,
useDeleteWorkspace,
useUpdateWorkspaceName,
useWorkspacesQuery,
type Workspace,
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useWorkspaceManagement')
interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
interface UseWorkspaceManagementProps {
workspaceId: string
sessionUserId?: string
}
/**
* Custom hook to manage workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Handles workspace validation and URL synchronization.
*
* @param props - Configuration object containing workspaceId and sessionUserId
* @returns Workspace management state and operations
* @param props.workspaceId - The current workspace ID from the URL
* @param props.sessionUserId - The current user's session ID
* @returns Workspace state and operations
*/
export function useWorkspaceManagement({
workspaceId,
@@ -33,140 +35,68 @@ export function useWorkspaceManagement({
}: UseWorkspaceManagementProps) {
const router = useRouter()
const pathname = usePathname()
const queryClient = useQueryClient()
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
// Workspace management state
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const {
data: workspaces = [],
isLoading: isWorkspacesLoading,
refetch: refetchWorkspaces,
} = useWorkspacesQuery(Boolean(sessionUserId))
const leaveWorkspaceMutation = useLeaveWorkspace()
const createWorkspaceMutation = useCreateWorkspace()
const deleteWorkspaceMutation = useDeleteWorkspace()
const updateWorkspaceNameMutation = useUpdateWorkspaceName()
// Refs to avoid dependency issues
const workspaceIdRef = useRef<string>(workspaceId)
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
const pathnameRef = useRef<string | null>(pathname || null)
const activeWorkspaceRef = useRef<Workspace | null>(null)
const isInitializedRef = useRef<boolean>(false)
const hasValidatedRef = useRef<boolean>(false)
// Update refs when values change
workspaceIdRef.current = workspaceId
routerRef.current = router
pathnameRef.current = pathname || null
const activeWorkspace = useMemo(() => {
if (!workspaces.length) return null
return workspaces.find((w) => w.id === workspaceId) ?? null
}, [workspaces, workspaceId])
const activeWorkspaceRef = useRef<Workspace | null>(activeWorkspace)
activeWorkspaceRef.current = activeWorkspace
/**
* Refresh workspace list without validation logic - used for non-current workspace operations
*/
const refreshWorkspaceList = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Only update activeWorkspace if it still exists in the fetched workspaces
// Use functional update to avoid dependency on activeWorkspace
setActiveWorkspace((currentActive) => {
if (!currentActive) {
return currentActive
}
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentActive.id
)
if (matchingWorkspace) {
return matchingWorkspace
}
// Active workspace was deleted, clear it
logger.warn(`Active workspace ${currentActive.id} no longer exists`)
return null
})
}
} catch (err) {
logger.error('Error refreshing workspace list:', err)
} finally {
setIsWorkspacesLoading(false)
useEffect(() => {
if (isWorkspacesLoading || hasValidatedRef.current || !workspaces.length) {
return
}
}, [])
const currentWorkspaceId = workspaceIdRef.current
const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
if (!matchingWorkspace) {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
const fallbackWorkspace = workspaces[0]
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
routerRef.current?.push(`/workspace/${fallbackWorkspace.id}/w`)
}
hasValidatedRef.current = true
}, [workspaces, isWorkspacesLoading])
const refreshWorkspaceList = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
}, [queryClient])
const fetchWorkspaces = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
hasValidatedRef.current = false
await refetchWorkspaces()
}, [refetchWorkspaces])
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Handle active workspace selection with URL validation using refs
const currentWorkspaceId = workspaceIdRef.current
const currentRouter = routerRef.current
if (currentWorkspaceId) {
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentWorkspaceId
)
if (matchingWorkspace) {
setActiveWorkspace(matchingWorkspace)
} else {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
// Fallback to first workspace if current not found
if (fetchedWorkspaces.length > 0) {
const fallbackWorkspace = fetchedWorkspaces[0]
setActiveWorkspace(fallbackWorkspace)
// Update URL to match the fallback workspace
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
currentRouter?.push(`/workspace/${fallbackWorkspace.id}/w`)
} else {
logger.error('No workspaces available for user')
}
}
}
}
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, [])
/**
* Update workspace name both in API and local state
*/
const updateWorkspaceName = useCallback(
async (workspaceId: string, newName: string): Promise<boolean> => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
// Update local state immediately after successful API call
// Only update activeWorkspace if it's the one being renamed
setActiveWorkspace((prev) =>
prev && prev.id === workspaceId ? { ...prev, name: newName.trim() } : prev
)
setWorkspaces((prev) =>
prev.map((workspace) =>
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace
)
)
await updateWorkspaceNameMutation.mutateAsync({ workspaceId, name: newName })
logger.info('Successfully updated workspace name to:', newName.trim())
return true
} catch (error) {
@@ -174,21 +104,18 @@ export function useWorkspaceManagement({
return false
}
},
[]
[updateWorkspaceNameMutation]
)
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
// If already on this workspace, return
if (activeWorkspaceRef.current?.id === workspace.id) {
return
}
try {
// Switch workspace and update URL
await switchToWorkspace(workspace.id)
const currentPath = pathnameRef.current || ''
// Preserve templates route if user is on templates or template detail
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
if (templateDetailMatch) {
const templateId = templateDetailMatch[1]
@@ -206,208 +133,122 @@ export function useWorkspaceManagement({
[switchToWorkspace]
)
/**
* Handle create workspace
*/
const handleCreateWorkspace = useCallback(async () => {
if (isCreatingWorkspace) {
if (createWorkspaceMutation.isPending) {
logger.info('Workspace creation already in progress, ignoring request')
return
}
try {
setIsCreatingWorkspace(true)
logger.info('Creating new workspace')
// Generate workspace name using utility function
const workspaceName = await generateWorkspaceName()
logger.info(`Generated workspace name: ${workspaceName}`)
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: workspaceName,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create workspace')
}
const data = await response.json()
const newWorkspace = data.workspace
const newWorkspace = await createWorkspaceMutation.mutateAsync({ name: workspaceName })
logger.info('Created new workspace:', newWorkspace)
// Refresh workspace list (no URL validation needed for creation)
await refreshWorkspaceList()
// Switch to the new workspace
await switchWorkspace(newWorkspace)
} catch (error) {
logger.error('Error creating workspace:', error)
} finally {
setIsCreatingWorkspace(false)
}
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
}, [createWorkspaceMutation, switchWorkspace])
/**
* Confirm delete workspace
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
setIsDeleting(true)
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
const deleteTemplates = templateAction === 'delete'
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ deleteTemplates }),
await deleteWorkspaceMutation.mutateAsync({
workspaceId: workspaceToDelete.id,
deleteTemplates,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete workspace')
}
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
// Check if we're deleting the current workspace (either active or in URL)
const isDeletingCurrentWorkspace =
workspaceIdRef.current === workspaceToDelete.id ||
activeWorkspaceRef.current?.id === workspaceToDelete.id
if (isDeletingCurrentWorkspace) {
// For current workspace deletion, use full fetchWorkspaces with URL validation
logger.info(
'Deleting current workspace - using full workspace refresh with URL validation'
)
await fetchWorkspaces()
hasValidatedRef.current = false
const { data: updatedWorkspaces } = await refetchWorkspaces()
// If we deleted the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToDelete.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToDelete.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
const remainingWorkspaces = (updatedWorkspaces || []).filter(
(w) => w.id !== workspaceToDelete.id
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
} else {
// For non-current workspace deletion, just refresh the list without URL validation
logger.info('Deleting non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error deleting workspace:', error)
} finally {
setIsDeleting(false)
}
},
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
[deleteWorkspaceMutation, refetchWorkspaces, switchWorkspace]
)
/**
* Handle leave workspace
*/
const handleLeaveWorkspace = useCallback(
async (workspaceToLeave: Workspace) => {
setIsLeaving(true)
if (!sessionUserId) {
logger.error('Cannot leave workspace: no session user ID')
return
}
logger.info('Leaving workspace:', workspaceToLeave.id)
try {
logger.info('Leaving workspace:', workspaceToLeave.id)
// Use the existing member removal API with current user's ID
const response = await fetch(`/api/workspaces/members/${sessionUserId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: workspaceToLeave.id,
}),
await leaveWorkspaceMutation.mutateAsync({
userId: sessionUserId,
workspaceId: workspaceToLeave.id,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to leave workspace')
}
logger.info('Left workspace successfully:', workspaceToLeave.id)
// Check if we're leaving the current workspace (either active or in URL)
const isLeavingCurrentWorkspace =
workspaceIdRef.current === workspaceToLeave.id ||
activeWorkspaceRef.current?.id === workspaceToLeave.id
if (isLeavingCurrentWorkspace) {
// For current workspace leaving, use full fetchWorkspaces with URL validation
logger.info(
'Leaving current workspace - using full workspace refresh with URL validation'
)
await fetchWorkspaces()
hasValidatedRef.current = false
const { data: updatedWorkspaces } = await refetchWorkspaces()
// If we left the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToLeave.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToLeave.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
const remainingWorkspaces = (updatedWorkspaces || []).filter(
(w) => w.id !== workspaceToLeave.id
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
} else {
// For non-current workspace leaving, just refresh the list without URL validation
logger.info('Leaving non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error leaving workspace:', error)
} finally {
setIsLeaving(false)
throw error
}
},
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace, sessionUserId]
[refetchWorkspaces, switchWorkspace, sessionUserId, leaveWorkspaceMutation]
)
/**
* Validate workspace exists before making API calls
*/
const isWorkspaceValid = useCallback(async (workspaceId: string) => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`)
return response.ok
} catch {
return false
}
}, [])
/**
* Initialize workspace data on mount (uses full validation with URL handling)
* fetchWorkspaces is stable (empty deps array), so it's safe to call without including it
*/
useEffect(() => {
if (sessionUserId && !isInitializedRef.current) {
isInitializedRef.current = true
fetchWorkspaces()
}
}, [sessionUserId, fetchWorkspaces])
const isWorkspaceValid = useCallback(
(targetWorkspaceId: string) => {
return workspaces.some((w) => w.id === targetWorkspaceId)
},
[workspaces]
)
return {
// State
workspaces,
activeWorkspace,
isWorkspacesLoading,
isCreatingWorkspace,
isDeleting,
isLeaving,
// Operations
isCreatingWorkspace: createWorkspaceMutation.isPending,
isDeleting: deleteWorkspaceMutation.isPending,
isLeaving: leaveWorkspaceMutation.isPending,
fetchWorkspaces,
refreshWorkspaceList,
updateWorkspaceName,
@@ -418,3 +259,5 @@ export function useWorkspaceManagement({
isWorkspaceValid,
}
}
export type { Workspace }

View File

@@ -13,8 +13,8 @@ interface FreeTierUpgradeEmailProps {
const proFeatures = [
{ label: '$20/month', desc: 'in credits included' },
{ label: '25 runs/min', desc: 'sync executions' },
{ label: '200 runs/min', desc: 'async executions' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '1,000 runs/min', desc: 'async executions' },
{ label: '50GB storage', desc: 'for files & assets' },
{ label: 'Unlimited', desc: 'workspaces & invites' },
]

View File

@@ -322,7 +322,8 @@ describe('ConditionBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
// collectBlockData is now called with the current node ID for parallel branch context
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext, mockBlock.id)
})
it('should handle function_execute tool failure', async () => {
@@ -620,4 +621,248 @@ describe('ConditionBlockHandler', () => {
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
})
describe('Parallel branch handling', () => {
it('should resolve connections and block data correctly when inside a parallel branch', async () => {
// Simulate a condition block inside a parallel branch
// Virtual block ID uses subscript notation: blockId₍branchIndex₎
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍0₎', // Virtual ID for branch 0
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
// Source block also has a virtual ID in the same branch
const sourceBlockVirtualId = 'agent-block-1₍0₎'
// Set up workflow with connections using BASE block IDs (as they are in the workflow definition)
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
// Connections use base IDs, not virtual IDs
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
// Block states use virtual IDs (as outputs are stored per-branch)
const parallelBlockStates = new Map<string, BlockState>([
[
sourceBlockVirtualId,
{ output: { response: 'hello from branch 0', success: true }, executed: true },
],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.response === "hello from branch 0"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// The condition should evaluate to true because:
// 1. Connection lookup uses base ID 'cond-block-1' (extracted from 'cond-block-1₍0₎')
// 2. Source block output is found at virtual ID 'agent-block-1₍0₎' (same branch)
// 3. The evaluation context contains { response: 'hello from branch 0' }
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: 'target-block-1',
blockType: 'api',
blockTitle: 'Target',
})
})
it('should find correct source block output in parallel branch context', async () => {
// Test that when multiple branches exist, the correct branch output is used
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍1₎', // Virtual ID for branch 1
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
// Multiple branches have executed - each has different output
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 10 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 25 }, executed: true }], // Branch 1 has value 25
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
// Condition checks if value > 20 - should be true for branch 1 (value=25)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// Should evaluate using branch 1's data (value=25), not branch 0 (value=10) or branch 2 (value=5)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
})
it('should fall back to else when condition is false in parallel branch', async () => {
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍2₎', // Virtual ID for branch 2
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-true',
metadata: { id: 'api', name: 'True Path' },
position: { x: 200, y: 0 },
config: {},
},
{
id: 'target-false',
metadata: { id: 'api', name: 'False Path' },
position: { x: 200, y: 100 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-true', sourceHandle: 'condition-cond1' },
{ source: 'cond-block-1', target: 'target-false', sourceHandle: 'condition-else1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 100 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 50 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }], // Branch 2 has value 5
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
// Condition checks if value > 20 - should be false for branch 2 (value=5)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// Should fall back to else path because branch 2's value (5) is not > 20
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath.blockId).toBe('target-false')
})
})
})

View File

@@ -3,6 +3,12 @@ import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
buildBranchNodeId,
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -18,7 +24,8 @@ const CONDITION_TIMEOUT_MS = 5000
export async function evaluateConditionExpression(
ctx: ExecutionContext,
conditionExpression: string,
providedEvalContext?: Record<string, any>
providedEvalContext?: Record<string, any>,
currentNodeId?: string
): Promise<boolean> {
const evalContext = providedEvalContext || {}
@@ -26,7 +33,7 @@ export async function evaluateConditionExpression(
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
const result = await executeTool(
'function_execute',
@@ -83,7 +90,19 @@ export class ConditionBlockHandler implements BlockHandler {
): Promise<BlockOutput> {
const conditions = this.parseConditions(inputs.conditions)
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
const baseBlockId = extractBaseBlockId(block.id)
const branchIndex = isBranchNodeId(block.id) ? extractBranchIndex(block.id) : null
const sourceConnection = ctx.workflow?.connections.find((conn) => conn.target === baseBlockId)
let sourceBlockId = sourceConnection?.source
if (sourceBlockId && branchIndex !== null) {
const virtualSourceId = buildBranchNodeId(sourceBlockId, branchIndex)
if (ctx.blockStates.has(virtualSourceId)) {
sourceBlockId = virtualSourceId
}
}
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
@@ -91,13 +110,16 @@ export class ConditionBlockHandler implements BlockHandler {
// thinking this block is pausing (it was already resumed by the HITL block)
const sourceOutput = this.filterPauseMetadata(rawSourceOutput)
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
const outgoingConnections = ctx.workflow?.connections.filter(
(conn) => conn.source === baseBlockId
)
const { selectedConnection, selectedCondition } = await this.evaluateConditions(
conditions,
outgoingConnections || [],
evalContext,
ctx
ctx,
block.id
)
if (!selectedConnection || !selectedCondition) {
@@ -170,7 +192,8 @@ export class ConditionBlockHandler implements BlockHandler {
conditions: Array<{ id: string; title: string; value: string }>,
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
evalContext: Record<string, any>,
ctx: ExecutionContext
ctx: ExecutionContext,
currentNodeId?: string
): Promise<{
selectedConnection: { target: string; sourceHandle?: string } | null
selectedCondition: { id: string; title: string; value: string } | null
@@ -189,7 +212,8 @@ export class ConditionBlockHandler implements BlockHandler {
const conditionMet = await evaluateConditionExpression(
ctx,
conditionValueString,
evalContext
evalContext,
currentNodeId
)
if (conditionMet) {

View File

@@ -2,6 +2,11 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference'
import {
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
@@ -86,14 +91,30 @@ export function getBlockSchema(
return undefined
}
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
export function collectBlockData(
ctx: ExecutionContext,
currentNodeId?: string
): BlockDataCollection {
const blockData: Record<string, unknown> = {}
const blockNameMapping: Record<string, string> = {}
const blockOutputSchemas: Record<string, OutputSchema> = {}
const branchIndex =
currentNodeId && isBranchNodeId(currentNodeId) ? extractBranchIndex(currentNodeId) : null
for (const [id, state] of ctx.blockStates.entries()) {
if (state.output !== undefined) {
blockData[id] = state.output
if (branchIndex !== null && isBranchNodeId(id)) {
const stateBranchIndex = extractBranchIndex(id)
if (stateBranchIndex === branchIndex) {
const baseId = extractBaseBlockId(id)
if (blockData[baseId] === undefined) {
blockData[baseId] = state.output
}
}
}
}
}

View File

@@ -0,0 +1,309 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { workspaceKeys } from './workspace'
/**
* Query key factory for invitation-related queries.
* Provides hierarchical cache keys for workspace invitations.
*/
export const invitationKeys = {
all: ['invitations'] as const,
lists: () => [...invitationKeys.all, 'list'] as const,
list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const,
}
/** Raw invitation data from the API. */
export interface PendingInvitation {
id: string
workspaceId: string
email: string
permissions: 'admin' | 'write' | 'read'
status: string
createdAt: string
}
/** Normalized invitation for display in the UI. */
export interface WorkspaceInvitation {
email: string
permissionType: 'admin' | 'write' | 'read'
isPendingInvitation: boolean
invitationId?: string
}
async function fetchPendingInvitations(workspaceId: string): Promise<WorkspaceInvitation[]> {
const response = await fetch('/api/workspaces/invitations')
if (!response.ok) {
throw new Error('Failed to fetch pending invitations')
}
const data = await response.json()
return (
data.invitations
?.filter(
(inv: PendingInvitation) => inv.status === 'pending' && inv.workspaceId === workspaceId
)
.map((inv: PendingInvitation) => ({
email: inv.email,
permissionType: inv.permissions,
isPendingInvitation: true,
invitationId: inv.id,
})) || []
)
}
/**
* Fetches pending invitations for a workspace.
* @param workspaceId - The workspace ID to fetch invitations for
*/
export function usePendingInvitations(workspaceId: string | undefined) {
return useQuery({
queryKey: invitationKeys.list(workspaceId ?? ''),
queryFn: () => fetchPendingInvitations(workspaceId as string),
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface BatchSendInvitationsParams {
workspaceId: string
invitations: Array<{ email: string; permission: 'admin' | 'write' | 'read' }>
}
interface BatchInvitationResult {
successful: string[]
failed: Array<{ email: string; error: string }>
}
/**
* Sends multiple workspace invitations in parallel.
* Returns results for each invitation indicating success or failure.
*/
export function useBatchSendWorkspaceInvitations() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
invitations,
}: BatchSendInvitationsParams): Promise<BatchInvitationResult> => {
const results = await Promise.allSettled(
invitations.map(async ({ email, permission }) => {
const response = await fetch('/api/workspaces/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
email,
role: 'member',
permission,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to send invitation')
}
return { email, data: await response.json() }
})
)
const successful: string[] = []
const failed: Array<{ email: string; error: string }> = []
results.forEach((result, index) => {
const email = invitations[index].email
if (result.status === 'fulfilled') {
successful.push(email)
} else {
failed.push({ email, error: result.reason?.message || 'Unknown error' })
}
})
return { successful, failed }
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface CancelInvitationParams {
invitationId: string
workspaceId: string
}
/**
* Cancels a pending workspace invitation.
* Invalidates the invitation list cache on success.
*/
export function useCancelWorkspaceInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to cancel invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface ResendInvitationParams {
invitationId: string
workspaceId: string
}
/**
* Resends a pending workspace invitation email.
* Invalidates the invitation list cache on success.
*/
export function useResendWorkspaceInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ invitationId }: ResendInvitationParams) => {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to resend invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface RemoveMemberParams {
userId: string
workspaceId: string
}
/**
* Removes a member from a workspace.
* Invalidates the workspace permissions cache on success.
*/
export function useRemoveWorkspaceMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, workspaceId }: RemoveMemberParams) => {
const response = await fetch(`/api/workspaces/members/${userId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to remove member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
},
})
}
interface LeaveWorkspaceParams {
userId: string
workspaceId: string
}
/**
* Allows the current user to leave a workspace.
* Invalidates both permissions and workspace list caches on success.
*/
export function useLeaveWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, workspaceId }: LeaveWorkspaceParams) => {
const response = await fetch(`/api/workspaces/members/${userId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to leave workspace')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceKeys.all,
})
},
})
}
interface UpdatePermissionsParams {
workspaceId: string
updates: Array<{ userId: string; permissions: 'admin' | 'write' | 'read' }>
}
/**
* Updates permissions for one or more workspace members.
* Invalidates the workspace permissions cache on success.
*/
export function useUpdateWorkspacePermissions() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, updates }: UpdatePermissionsParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update permissions')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
},
})
}

View File

@@ -6,27 +6,32 @@ import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsQuery')
/**
* Query key factories for OAuth connections
* Query key factory for OAuth connection queries.
* Provides hierarchical cache keys for connections and provider-specific accounts.
*/
export const oauthConnectionsKeys = {
all: ['oauthConnections'] as const,
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
accounts: (provider: string) => [...oauthConnectionsKeys.all, 'accounts', provider] as const,
}
/**
* Service info type - extends OAuthServiceConfig with connection status and the service key
*/
/** OAuth service with connection status and linked accounts. */
export interface ServiceInfo extends OAuthServiceConfig {
/** The service key from OAUTH_PROVIDERS (e.g., 'gmail', 'google-drive') */
id: string
isConnected: boolean
lastConnected?: string
accounts?: { id: string; name: string }[]
}
/**
* Define available services from standardized OAuth providers
*/
/** OAuth connection data returned from the API. */
interface OAuthConnectionResponse {
provider: string
baseProvider?: string
accounts?: { id: string; name: string }[]
lastConnected?: string
scopes?: string[]
}
function defineServices(): ServiceInfo[] {
const servicesList: ServiceInfo[] = []
@@ -44,9 +49,6 @@ function defineServices(): ServiceInfo[] {
return servicesList
}
/**
* Fetch OAuth connections and merge with service definitions
*/
async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
try {
const serviceDefinitions = defineServices()
@@ -65,7 +67,9 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
const connections = data.connections || []
const updatedServices = serviceDefinitions.map((service) => {
const connection = connections.find((conn: any) => conn.provider === service.providerId)
const connection = connections.find(
(conn: OAuthConnectionResponse) => conn.provider === service.providerId
)
if (connection) {
return {
@@ -76,13 +80,14 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
}
const connectionWithScopes = connections.find((conn: any) => {
const connectionWithScopes = connections.find((conn: OAuthConnectionResponse) => {
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
return false
}
if (conn.scopes && service.scopes) {
return service.scopes.every((scope) => conn.scopes.includes(scope))
const connScopes = conn.scopes
return service.scopes.every((scope) => connScopes.includes(scope))
}
return false
@@ -108,26 +113,28 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
/**
* Hook to fetch OAuth connections
* Fetches all OAuth service connections with their status.
* Returns service definitions merged with connection data.
*/
export function useOAuthConnections() {
return useQuery({
queryKey: oauthConnectionsKeys.connections(),
queryFn: fetchOAuthConnections,
staleTime: 30 * 1000, // 30 seconds - connections don't change often
retry: false, // Don't retry on 404
placeholderData: keepPreviousData, // Show cached data immediately
staleTime: 30 * 1000,
retry: false,
placeholderData: keepPreviousData,
})
}
/**
* Connect OAuth service mutation
*/
interface ConnectServiceParams {
providerId: string
callbackURL: string
}
/**
* Initiates OAuth connection flow for a service.
* Redirects the user to the provider's authorization page.
*/
export function useConnectOAuthService() {
const queryClient = useQueryClient()
@@ -138,7 +145,6 @@ export function useConnectOAuthService() {
return { success: true }
}
// Shopify requires a custom OAuth flow with shop domain input
if (providerId === 'shopify') {
const returnUrl = encodeURIComponent(callbackURL)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
@@ -161,9 +167,6 @@ export function useConnectOAuthService() {
})
}
/**
* Disconnect OAuth service mutation
*/
interface DisconnectServiceParams {
provider: string
providerId: string
@@ -171,6 +174,10 @@ interface DisconnectServiceParams {
accountId: string
}
/**
* Disconnects an OAuth service account.
* Performs optimistic update and rolls back on failure.
*/
export function useDisconnectOAuthService() {
const queryClient = useQueryClient()
@@ -230,3 +237,38 @@ export function useDisconnectOAuthService() {
},
})
}
/** Connected OAuth account for a specific provider. */
export interface ConnectedAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
async function fetchConnectedAccounts(provider: string): Promise<ConnectedAccount[]> {
const response = await fetch(`/api/auth/accounts?provider=${provider}`)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || `Failed to load ${provider} accounts`)
}
const data = await response.json()
return data.accounts || []
}
/**
* Fetches connected accounts for a specific OAuth provider.
* @param provider - The provider ID (e.g., 'slack', 'google')
* @param options - Query options including enabled flag
*/
export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: oauthConnectionsKeys.accounts(provider),
queryFn: () => fetchConnectedAccounts(provider),
enabled: options?.enabled ?? true,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -1,10 +1,13 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
/**
* Query key factories for workspace-related queries
* Query key factory for workspace-related queries.
* Provides hierarchical cache keys for workspaces, settings, and permissions.
*/
export const workspaceKeys = {
all: ['workspace'] as const,
lists: () => [...workspaceKeys.all, 'list'] as const,
list: () => [...workspaceKeys.lists(), 'user'] as const,
details: () => [...workspaceKeys.all, 'detail'] as const,
detail: (id: string) => [...workspaceKeys.details(), id] as const,
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
@@ -13,9 +16,186 @@ export const workspaceKeys = {
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
/** Represents a workspace in the user's workspace list. */
export interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
async function fetchWorkspaces(): Promise<Workspace[]> {
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
return data.workspaces || []
}
/**
* Fetch workspace settings
* Fetches the current user's workspaces.
* @param enabled - Whether the query should execute (defaults to true)
*/
export function useWorkspacesQuery(enabled = true) {
return useQuery({
queryKey: workspaceKeys.list(),
queryFn: fetchWorkspaces,
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface CreateWorkspaceParams {
name: string
}
/**
* Creates a new workspace.
* Automatically invalidates the workspace list cache on success.
*/
export function useCreateWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name }: CreateWorkspaceParams) => {
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create workspace')
}
const data = await response.json()
return data.workspace as Workspace
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
},
})
}
interface DeleteWorkspaceParams {
workspaceId: string
deleteTemplates?: boolean
}
/**
* Deletes a workspace.
* Automatically invalidates the workspace list cache on success.
*/
export function useDeleteWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, deleteTemplates = false }: DeleteWorkspaceParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deleteTemplates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete workspace')
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
},
})
}
interface UpdateWorkspaceNameParams {
workspaceId: string
name: string
}
/**
* Updates a workspace's name.
* Invalidates both the workspace list and the specific workspace detail cache.
*/
export function useUpdateWorkspaceName() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, name }: UpdateWorkspaceNameParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
},
})
}
/** Represents a user with permissions in a workspace. */
export interface WorkspaceUser {
userId: string
email: string
name: string | null
image: string | null
permissionType: 'admin' | 'write' | 'read'
}
/** Workspace permissions data containing all users and their access levels. */
export interface WorkspacePermissions {
users: WorkspaceUser[]
total: number
}
async function fetchWorkspacePermissions(workspaceId: string): Promise<WorkspacePermissions> {
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Workspace not found or access denied')
}
if (response.status === 401) {
throw new Error('Authentication required')
}
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
}
return response.json()
}
/**
* Fetches permissions for a specific workspace.
* @param workspaceId - The workspace ID to fetch permissions for
*/
export function useWorkspacePermissionsQuery(workspaceId: string | null | undefined) {
return useQuery({
queryKey: workspaceKeys.permissions(workspaceId ?? ''),
queryFn: () => fetchWorkspacePermissions(workspaceId as string),
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
async function fetchWorkspaceSettings(workspaceId: string) {
const [settingsResponse, permissionsResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}`),
@@ -38,7 +218,8 @@ async function fetchWorkspaceSettings(workspaceId: string) {
}
/**
* Hook to fetch workspace settings
* Fetches workspace settings including permissions.
* @param workspaceId - The workspace ID to fetch settings for
*/
export function useWorkspaceSettings(workspaceId: string) {
return useQuery({
@@ -50,15 +231,16 @@ export function useWorkspaceSettings(workspaceId: string) {
})
}
/**
* Update workspace settings mutation
*/
interface UpdateWorkspaceSettingsParams {
workspaceId: string
billedAccountUserId?: string
billingAccountUserEmail?: string
}
/**
* Updates workspace settings (e.g., billing configuration).
* Invalidates the workspace settings cache on success.
*/
export function useUpdateWorkspaceSettings() {
const queryClient = useQueryClient()
@@ -85,9 +267,7 @@ export function useUpdateWorkspaceSettings() {
})
}
/**
* Workspace type returned by admin workspaces query
*/
/** Workspace with admin access metadata. */
export interface AdminWorkspace {
id: string
name: string
@@ -96,9 +276,6 @@ export interface AdminWorkspace {
canInvite: boolean
}
/**
* Fetch workspaces where user has admin access
*/
async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWorkspace[]> {
if (!userId) {
return []
@@ -121,7 +298,7 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
}
const permissionData = await permissionResponse.json()
return { workspace, permissionData }
} catch (error) {
} catch (_error) {
return null
}
}
@@ -161,14 +338,15 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
}
/**
* Hook to fetch workspaces where user has admin access
* Fetches workspaces where the user has admin access.
* @param userId - The user ID to check admin access for
*/
export function useAdminWorkspaces(userId: string | undefined) {
return useQuery({
queryKey: workspaceKeys.adminList(userId),
queryFn: () => fetchAdminWorkspaces(userId),
enabled: Boolean(userId),
staleTime: 60 * 1000, // Cache for 60 seconds
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -1,52 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
interface SlackAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
interface UseSlackAccountsResult {
accounts: SlackAccount[]
isLoading: boolean
error: string | null
refetch: () => Promise<void>
}
/**
* Fetches and manages connected Slack accounts for the current user.
* @returns Object containing accounts array, loading state, error state, and refetch function
*/
export function useSlackAccounts(): UseSlackAccountsResult {
const [accounts, setAccounts] = useState<SlackAccount[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccounts = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch('/api/auth/accounts?provider=slack')
if (response.ok) {
const data = await response.json()
setAccounts(data.accounts || [])
} else {
const data = await response.json().catch(() => ({}))
setError(data.error || 'Failed to load Slack accounts')
setAccounts([])
}
} catch {
setError('Failed to load Slack accounts')
setAccounts([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchAccounts()
}, [])
return { accounts, isLoading, error, refetch: fetchAccounts }
}

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useSession } from '@/lib/auth/auth-client'
import type { PermissionType, WorkspacePermissions } from '@/hooks/use-workspace-permissions'
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
export type PermissionType = 'admin' | 'write' | 'read'
const logger = createLogger('useUserPermissions')

View File

@@ -1,107 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { permissionTypeEnum } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { API_ENDPOINTS } from '@/stores/constants'
const logger = createLogger('useWorkspacePermissions')
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
export interface WorkspaceUser {
userId: string
email: string
name: string | null
image: string | null
permissionType: PermissionType
}
export interface WorkspacePermissions {
users: WorkspaceUser[]
total: number
}
interface UseWorkspacePermissionsReturn {
permissions: WorkspacePermissions | null
loading: boolean
error: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetch: () => Promise<void>
}
/**
* Custom hook to fetch and manage workspace permissions
*
* @param workspaceId - The workspace ID to fetch permissions for
* @returns Object containing permissions data, loading state, error state, and refetch function
*/
export function useWorkspacePermissions(workspaceId: string | null): UseWorkspacePermissionsReturn {
const [permissions, setPermissions] = useState<WorkspacePermissions | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchPermissions = async (id: string): Promise<void> => {
try {
setLoading(true)
setError(null)
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(id))
if (!response.ok) {
if (response.status === 404) {
throw new Error('Workspace not found or access denied')
}
if (response.status === 401) {
throw new Error('Authentication required')
}
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
}
const data: WorkspacePermissions = await response.json()
setPermissions(data)
logger.info('Workspace permissions loaded', {
workspaceId: id,
userCount: data.total,
users: data.users.map((u) => ({ email: u.email, permissions: u.permissionType })),
})
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
setError(errorMessage)
logger.error('Failed to fetch workspace permissions', {
workspaceId: id,
error: errorMessage,
})
} finally {
setLoading(false)
}
}
const updatePermissions = useCallback((newPermissions: WorkspacePermissions): void => {
setPermissions(newPermissions)
}, [])
useEffect(() => {
if (workspaceId) {
fetchPermissions(workspaceId)
} else {
// Clear state if no workspace ID
setPermissions(null)
setError(null)
setLoading(false)
}
}, [workspaceId])
const refetch = useCallback(async () => {
if (workspaceId) {
await fetchPermissions(workspaceId)
}
}, [workspaceId])
return {
permissions,
loading,
error,
updatePermissions,
refetch,
}
}

View File

@@ -30,7 +30,7 @@ import {
ensureOrganizationForTeamSubscription,
syncSubscriptionUsageLimits,
} from '@/lib/billing/organization'
import { getPlans } from '@/lib/billing/plans'
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
@@ -2641,29 +2641,42 @@ export const auth = betterAuth({
}
},
onSubscriptionComplete: async ({
stripeSubscription,
subscription,
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
const { priceId, planFromStripe, isTeamPlan } =
resolvePlanFromStripeSubscription(stripeSubscription)
logger.info('[onSubscriptionComplete] Subscription created', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
priceId,
status: subscription.status,
})
const subscriptionForOrgCreation = isTeamPlan
? { ...subscription, plan: 'team' }
: subscription
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
} catch (orgError) {
logger.error(
'[onSubscriptionComplete] Failed to ensure organization for team subscription',
{
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
error: orgError instanceof Error ? orgError.message : String(orgError),
stack: orgError instanceof Error ? orgError.stack : undefined,
}
@@ -2684,22 +2697,67 @@ export const auth = betterAuth({
event: Stripe.Event
subscription: any
}) => {
const stripeSubscription = event.data.object as Stripe.Subscription
const { priceId, planFromStripe, isTeamPlan } =
resolvePlanFromStripeSubscription(stripeSubscription)
if (priceId && !planFromStripe) {
logger.warn(
'[onSubscriptionUpdate] Could not determine plan from Stripe price ID',
{
subscriptionId: subscription.id,
priceId,
dbPlan: subscription.plan,
}
)
}
const isUpgradeToTeam =
isTeamPlan &&
subscription.plan !== 'team' &&
!subscription.referenceId.startsWith('org_')
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
logger.info('[onSubscriptionUpdate] Subscription updated', {
subscriptionId: subscription.id,
status: subscription.status,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
referenceId: subscription.referenceId,
})
const subscriptionForOrgCreation = isUpgradeToTeam
? { ...subscription, plan: 'team' }
: subscription
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
if (isUpgradeToTeam) {
logger.info(
'[onSubscriptionUpdate] Detected Pro -> Team upgrade, ensured organization creation',
{
subscriptionId: subscription.id,
originalPlan: subscription.plan,
newPlan: planFromStripe,
resolvedReferenceId: resolvedSubscription.referenceId,
}
)
}
} catch (orgError) {
logger.error(
'[onSubscriptionUpdate] Failed to ensure organization for team subscription',
{
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
error: orgError instanceof Error ? orgError.message : String(orgError),
stack: orgError instanceof Error ? orgError.stack : undefined,
}
@@ -2717,9 +2775,8 @@ export const auth = betterAuth({
})
}
if (resolvedSubscription.plan === 'team') {
if (effectivePlanForTeamFeatures === 'team') {
try {
const stripeSubscription = event.data.object as Stripe.Subscription
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
const result = await syncSeatsFromStripeQuantity(

View File

@@ -1,3 +1,4 @@
import type Stripe from 'stripe'
import {
getFreeTierLimit,
getProTierLimit,
@@ -56,6 +57,13 @@ export function getPlanByName(planName: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.name === planName)
}
/**
* Get a specific plan by Stripe price ID
*/
export function getPlanByPriceId(priceId: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.priceId === priceId)
}
/**
* Get plan limits for a given plan name
*/
@@ -63,3 +71,26 @@ export function getPlanLimits(planName: string): number {
const plan = getPlanByName(planName)
return plan?.limits.cost ?? getFreeTierLimit()
}
export interface StripePlanResolution {
priceId: string | undefined
planFromStripe: string | null
isTeamPlan: boolean
}
/**
* Resolve plan information from a Stripe subscription object.
* Used to get the authoritative plan from Stripe rather than relying on DB state.
*/
export function resolvePlanFromStripeSubscription(
stripeSubscription: Stripe.Subscription
): StripePlanResolution {
const priceId = stripeSubscription?.items?.data?.[0]?.price?.id
const plan = priceId ? getPlanByPriceId(priceId) : undefined
return {
priceId,
planFromStripe: plan?.name ?? null,
isTeamPlan: plan?.name === 'team',
}
}

View File

@@ -161,14 +161,14 @@ export const env = createEnv({
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute
RATE_LIMIT_FREE_SYNC: z.string().optional().default('50'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('200'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('150'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('1000'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('300'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('2500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
// Knowledge Base Processing Configuration - Shared across all processing methods
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)

View File

@@ -28,24 +28,24 @@ function createBucketConfig(ratePerMinute: number, burstMultiplier = 2): TokenBu
export const RATE_LIMITS: Record<SubscriptionPlan, RateLimitConfig> = {
free: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 10),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 50),
apiEndpoint: createBucketConfig(10),
},
pro: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 25),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 200),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 50),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 200),
apiEndpoint: createBucketConfig(30),
},
pro: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 150),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 1000),
apiEndpoint: createBucketConfig(100),
},
team: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 75),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 500),
apiEndpoint: createBucketConfig(60),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 300),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 2500),
apiEndpoint: createBucketConfig(200),
},
enterprise: {
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 150),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 1000),
apiEndpoint: createBucketConfig(120),
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 600),
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 5000),
apiEndpoint: createBucketConfig(500),
},
}

View File

@@ -34,7 +34,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 60000,
CLIENT_TIMEOUT: 600000,
MAX_RETRIES: 3,
RECONNECT_DELAY: 1000,
} as const

View File

@@ -81,8 +81,8 @@ describe('generateMcpServerId', () => {
})
describe('MCP_CONSTANTS', () => {
it.concurrent('has correct execution timeout', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(60000)
it.concurrent('has correct execution timeout (10 minutes)', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(600000)
})
it.concurrent('has correct cache timeout (5 minutes)', () => {
@@ -107,8 +107,8 @@ describe('MCP_CONSTANTS', () => {
})
describe('MCP_CLIENT_CONSTANTS', () => {
it.concurrent('has correct client timeout', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(60000)
it.concurrent('has correct client timeout (10 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(600000)
})
it.concurrent('has correct auto refresh interval (5 minutes)', () => {

View File

@@ -6,7 +6,7 @@ import { isMcpTool, MCP } from '@/executor/constants'
* MCP-specific constants
*/
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: 60000,
EXECUTION_TIMEOUT: 600000,
CACHE_TIMEOUT: 5 * 60 * 1000,
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
@@ -49,7 +49,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 60000,
CLIENT_TIMEOUT: 600000,
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
} as const

View File

@@ -1,6 +1,6 @@
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
id: 'a2a_cancel_task',

View File

@@ -1,6 +1,9 @@
import type {
A2ADeletePushNotificationParams,
A2ADeletePushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ADeletePushNotificationParams, A2ADeletePushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aDeletePushNotificationTool: ToolConfig<
A2ADeletePushNotificationParams,

View File

@@ -1,6 +1,6 @@
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
id: 'a2a_get_agent_card',

View File

@@ -1,6 +1,9 @@
import type {
A2AGetPushNotificationParams,
A2AGetPushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetPushNotificationParams, A2AGetPushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetPushNotificationTool: ToolConfig<
A2AGetPushNotificationParams,

View File

@@ -1,6 +1,6 @@
import type { A2AGetTaskParams, A2AGetTaskResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
id: 'a2a_get_task',

View File

@@ -1,6 +1,6 @@
import type { A2AResubscribeParams, A2AResubscribeResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AResubscribeParams, A2AResubscribeResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribeResponse> = {
id: 'a2a_resubscribe',

View File

@@ -1,6 +1,6 @@
import type { A2ASendMessageParams, A2ASendMessageResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
id: 'a2a_send_message',

View File

@@ -1,6 +1,9 @@
import type {
A2ASetPushNotificationParams,
A2ASetPushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ASetPushNotificationParams, A2ASetPushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aSetPushNotificationTool: ToolConfig<
A2ASetPushNotificationParams,

View File

@@ -1,5 +1,5 @@
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
import type { RunActorParams, RunActorResult } from './types'
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time

View File

@@ -1,5 +1,5 @@
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
import type { RunActorParams, RunActorResult } from './types'
export const apifyRunActorSyncTool: ToolConfig<RunActorParams, RunActorResult> = {
id: 'apify_run_actor_sync',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsAddAliasParams,
GoogleGroupsAddAliasResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse } from './types'
export const addAliasTool: ToolConfig<GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse> = {
id: 'google_groups_add_alias',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from './types'
export const addMemberTool: ToolConfig<GoogleGroupsAddMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_add_member',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from './types'
export const createGroupTool: ToolConfig<GoogleGroupsCreateParams, GoogleGroupsResponse> = {
id: 'google_groups_create_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from './types'
export const deleteGroupTool: ToolConfig<GoogleGroupsDeleteParams, GoogleGroupsResponse> = {
id: 'google_groups_delete_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from './types'
export const getGroupTool: ToolConfig<GoogleGroupsGetParams, GoogleGroupsResponse> = {
id: 'google_groups_get_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from './types'
export const getMemberTool: ToolConfig<GoogleGroupsGetMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_get_member',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsGetSettingsParams,
GoogleGroupsGetSettingsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetSettingsParams, GoogleGroupsGetSettingsResponse } from './types'
export const getSettingsTool: ToolConfig<
GoogleGroupsGetSettingsParams,

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from './types'
export const hasMemberTool: ToolConfig<GoogleGroupsHasMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_has_member',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsListAliasesParams,
GoogleGroupsListAliasesResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListAliasesParams, GoogleGroupsListAliasesResponse } from './types'
export const listAliasesTool: ToolConfig<
GoogleGroupsListAliasesParams,

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsListParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListParams, GoogleGroupsResponse } from './types'
export const listGroupsTool: ToolConfig<GoogleGroupsListParams, GoogleGroupsResponse> = {
id: 'google_groups_list_groups',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsListMembersParams,
GoogleGroupsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListMembersParams, GoogleGroupsResponse } from './types'
export const listMembersTool: ToolConfig<GoogleGroupsListMembersParams, GoogleGroupsResponse> = {
id: 'google_groups_list_members',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsRemoveAliasParams,
GoogleGroupsRemoveAliasResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsRemoveAliasParams, GoogleGroupsRemoveAliasResponse } from './types'
export const removeAliasTool: ToolConfig<
GoogleGroupsRemoveAliasParams,

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsRemoveMemberParams,
GoogleGroupsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsRemoveMemberParams, GoogleGroupsResponse } from './types'
export const removeMemberTool: ToolConfig<GoogleGroupsRemoveMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_remove_member',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from './types'
export const updateGroupTool: ToolConfig<GoogleGroupsUpdateParams, GoogleGroupsResponse> = {
id: 'google_groups_update_group',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsResponse,
GoogleGroupsUpdateMemberParams,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsResponse, GoogleGroupsUpdateMemberParams } from './types'
export const updateMemberTool: ToolConfig<GoogleGroupsUpdateMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_update_member',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsUpdateSettingsParams,
GoogleGroupsUpdateSettingsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsUpdateSettingsParams, GoogleGroupsUpdateSettingsResponse } from './types'
export const updateSettingsTool: ToolConfig<
GoogleGroupsUpdateSettingsParams,

View File

@@ -1,7 +1,7 @@
import { createHmac } from 'crypto'
import { v4 as uuidv4 } from 'uuid'
import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types'
import type { ToolConfig } from '@/tools/types'
import type { RequestResponse, WebhookRequestParams } from './types'
/**
* Generates HMAC-SHA256 signature for webhook payload

View File

@@ -1,8 +1,8 @@
import type { ToolConfig } from '@/tools/types'
import type {
IncidentioIncidentStatusesListParams,
IncidentioIncidentStatusesListResponse,
} from './types'
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
export const incidentStatusesListTool: ToolConfig<
IncidentioIncidentStatusesListParams,

View File

@@ -1,8 +1,8 @@
import type { ToolConfig } from '@/tools/types'
import type {
IncidentioIncidentTypesListParams,
IncidentioIncidentTypesListResponse,
} from './types'
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
export const incidentTypesListTool: ToolConfig<
IncidentioIncidentTypesListParams,

View File

@@ -1,5 +1,8 @@
import type {
IncidentioSeveritiesListParams,
IncidentioSeveritiesListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioSeveritiesListParams, IncidentioSeveritiesListResponse } from './types'
export const severitiesListTool: ToolConfig<
IncidentioSeveritiesListParams,

View File

@@ -1,5 +1,8 @@
import type {
IncidentioUsersListParams,
IncidentioUsersListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioUsersListParams, IncidentioUsersListResponse } from './types'
export const usersListTool: ToolConfig<IncidentioUsersListParams, IncidentioUsersListResponse> = {
id: 'incidentio_users_list',

View File

@@ -1,5 +1,8 @@
import type {
IncidentioUsersShowParams,
IncidentioUsersShowResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioUsersShowParams, IncidentioUsersShowResponse } from './types'
export const usersShowTool: ToolConfig<IncidentioUsersShowParams, IncidentioUsersShowResponse> = {
id: 'incidentio_users_show',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from './types'
export const workflowsCreateTool: ToolConfig<WorkflowsCreateParams, WorkflowsCreateResponse> = {
id: 'incidentio_workflows_create',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from './types'
export const workflowsDeleteTool: ToolConfig<WorkflowsDeleteParams, WorkflowsDeleteResponse> = {
id: 'incidentio_workflows_delete',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsListParams, WorkflowsListResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsListParams, WorkflowsListResponse } from './types'
export const workflowsListTool: ToolConfig<WorkflowsListParams, WorkflowsListResponse> = {
id: 'incidentio_workflows_list',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsShowParams, WorkflowsShowResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsShowParams, WorkflowsShowResponse } from './types'
export const workflowsShowTool: ToolConfig<WorkflowsShowParams, WorkflowsShowResponse> = {
id: 'incidentio_workflows_show',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from './types'
export const workflowsUpdateTool: ToolConfig<WorkflowsUpdateParams, WorkflowsUpdateResponse> = {
id: 'incidentio_workflows_update',

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomGetCompany')
export interface IntercomGetCompanyParams {
accessToken: string
companyId: string

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomGetConversation')
export interface IntercomGetConversationParams {
accessToken: string
conversationId: string

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListCompanies')
export interface IntercomListCompaniesParams {
accessToken: string
per_page?: number

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListContacts')
export interface IntercomListContactsParams {
accessToken: string
per_page?: number

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListConversations')
export interface IntercomListConversationsParams {
accessToken: string
per_page?: number

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomReplyConversation')
export interface IntercomReplyConversationParams {
accessToken: string
conversationId: string

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomSearchContacts')
export interface IntercomSearchContactsParams {
accessToken: string
query: string

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiAmendOrderParams extends KalshiAuthParams {
orderId: string // Order ID to amend (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiCancelOrderParams extends KalshiAuthParams {
orderId: string // Order ID to cancel (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiCreateOrderParams extends KalshiAuthParams {
ticker: string // Market ticker (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetBalanceParams extends KalshiAuthParams {}

View File

@@ -1,6 +1,6 @@
import type { KalshiCandlestick } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiCandlestick } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetCandlesticksParams {
seriesTicker: string

View File

@@ -1,6 +1,6 @@
import type { KalshiEvent } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiEvent } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetEventParams {
eventTicker: string // Event ticker

View File

@@ -1,6 +1,10 @@
import type { KalshiEvent, KalshiPaginationParams, KalshiPagingInfo } from '@/tools/kalshi/types'
import {
buildKalshiUrl,
handleKalshiError,
KALSHI_EVENT_OUTPUT_PROPERTIES,
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiEvent, KalshiPaginationParams, KalshiPagingInfo } from './types'
import { buildKalshiUrl, handleKalshiError, KALSHI_EVENT_OUTPUT_PROPERTIES } from './types'
export interface KalshiGetEventsParams extends KalshiPaginationParams {
status?: string // open, closed, settled

View File

@@ -1,6 +1,6 @@
import type { KalshiExchangeStatus } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiExchangeStatus } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export type KalshiGetExchangeStatusParams = Record<string, never>

View File

@@ -1,16 +1,16 @@
import type { ToolConfig } from '@/tools/types'
import type {
KalshiAuthParams,
KalshiFill,
KalshiPaginationParams,
KalshiPagingInfo,
} from './types'
} from '@/tools/kalshi/types'
import {
buildKalshiAuthHeaders,
buildKalshiUrl,
handleKalshiError,
KALSHI_FILL_OUTPUT_PROPERTIES,
} from './types'
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
export interface KalshiGetFillsParams extends KalshiAuthParams, KalshiPaginationParams {
ticker?: string

View File

@@ -1,6 +1,6 @@
import type { KalshiMarket } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiMarket } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetMarketParams {
ticker: string // Market ticker

View File

@@ -1,11 +1,11 @@
import type { ToolConfig } from '@/tools/types'
import type { KalshiMarket, KalshiPaginationParams, KalshiPagingInfo } from './types'
import type { KalshiMarket, KalshiPaginationParams, KalshiPagingInfo } from '@/tools/kalshi/types'
import {
buildKalshiUrl,
handleKalshiError,
KALSHI_MARKET_OUTPUT_PROPERTIES,
KALSHI_PAGING_OUTPUT_PROPERTIES,
} from './types'
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
export interface KalshiGetMarketsParams extends KalshiPaginationParams {
status?: string // unopened, open, closed, settled

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetOrderParams extends KalshiAuthParams {
orderId: string // Order ID to retrieve (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiOrderbook } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiOrderbook } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetOrderbookParams {
ticker: string

View File

@@ -1,16 +1,16 @@
import type { ToolConfig } from '@/tools/types'
import type {
KalshiAuthParams,
KalshiOrder,
KalshiPaginationParams,
KalshiPagingInfo,
} from './types'
} from '@/tools/kalshi/types'
import {
buildKalshiAuthHeaders,
buildKalshiUrl,
handleKalshiError,
KALSHI_ORDER_OUTPUT_PROPERTIES,
} from './types'
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
export interface KalshiGetOrdersParams extends KalshiAuthParams, KalshiPaginationParams {
ticker?: string

View File

@@ -1,17 +1,17 @@
import type { ToolConfig } from '@/tools/types'
import type {
KalshiAuthParams,
KalshiPaginationParams,
KalshiPagingInfo,
KalshiPosition,
} from './types'
} from '@/tools/kalshi/types'
import {
buildKalshiAuthHeaders,
buildKalshiUrl,
handleKalshiError,
KALSHI_EVENT_POSITION_OUTPUT_PROPERTIES,
KALSHI_POSITION_OUTPUT_PROPERTIES,
} from './types'
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
export interface KalshiGetPositionsParams extends KalshiAuthParams, KalshiPaginationParams {
ticker?: string

View File

@@ -1,6 +1,6 @@
import type { KalshiSeries } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiSeries } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetSeriesByTickerParams {
seriesTicker: string

View File

@@ -1,6 +1,10 @@
import type { KalshiPaginationParams, KalshiPagingInfo, KalshiTrade } from '@/tools/kalshi/types'
import {
buildKalshiUrl,
handleKalshiError,
KALSHI_TRADE_OUTPUT_PROPERTIES,
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiPaginationParams, KalshiPagingInfo, KalshiTrade } from './types'
import { buildKalshiUrl, handleKalshiError, KALSHI_TRADE_OUTPUT_PROPERTIES } from './types'
export interface KalshiGetTradesParams extends KalshiPaginationParams {}

View File

@@ -1,9 +1,6 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import type { OutputProperty } from '@/tools/types'
const logger = createLogger('Kalshi')
// Base URL for Kalshi API
export const KALSHI_BASE_URL = 'https://api.elections.kalshi.com/trade-api/v2'
@@ -598,8 +595,6 @@ export function buildKalshiAuthHeaders(
// Helper function for consistent error handling
export function handleKalshiError(data: any, status: number, operation: string): never {
logger.error(`Kalshi API request failed for ${operation}`, { data, status })
const errorMessage =
data.error?.message || data.error || data.message || data.detail || 'Unknown error'
throw new Error(`Kalshi ${operation} failed: ${errorMessage}`)

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import {
buildMailchimpUrl,
handleMailchimpError,
type MailchimpMember,
} from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import { buildMailchimpUrl, handleMailchimpError, type MailchimpMember } from './types'
const logger = createLogger('MailchimpAddMember')

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildMailchimpUrl, handleMailchimpError } from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import { buildMailchimpUrl, handleMailchimpError } from './types'
const logger = createLogger('MailchimpAddMemberTags')

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import {
buildMailchimpUrl,
handleMailchimpError,
type MailchimpMember,
} from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import { buildMailchimpUrl, handleMailchimpError, type MailchimpMember } from './types'
const logger = createLogger('MailchimpAddOrUpdateMember')

View File

@@ -1,8 +1,9 @@
import { createLogger } from '@sim/logger'
import {
buildMailchimpUrl,
handleMailchimpError,
type MailchimpMember,
} from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import { buildMailchimpUrl, handleMailchimpError, type MailchimpMember } from './types'
const logger = createLogger('MailchimpAddSegmentMember')
export interface MailchimpAddSegmentMemberParams {
apiKey: string

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import type { MailchimpMember } from '@/tools/mailchimp/types'
import { buildMailchimpUrl, handleMailchimpError } from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import type { MailchimpMember } from './types'
import { buildMailchimpUrl, handleMailchimpError } from './types'
const logger = createLogger('MailchimpAddSubscriberToAutomation')
export interface MailchimpAddSubscriberToAutomationParams {
apiKey: string

View File

@@ -1,8 +1,5 @@
import { createLogger } from '@sim/logger'
import { buildMailchimpUrl, handleMailchimpError } from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import { buildMailchimpUrl, handleMailchimpError } from './types'
const logger = createLogger('MailchimpArchiveMember')
export interface MailchimpArchiveMemberParams {
apiKey: string

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import type { MailchimpAudience } from '@/tools/mailchimp/types'
import { buildMailchimpUrl, handleMailchimpError } from '@/tools/mailchimp/types'
import type { ToolConfig } from '@/tools/types'
import type { MailchimpAudience } from './types'
import { buildMailchimpUrl, handleMailchimpError } from './types'
const logger = createLogger('MailchimpCreateAudience')

Some files were not shown because too many files have changed in this diff Show More